From db4b4b8b87e373140ba1f2dbe3accbbac1591b61 Mon Sep 17 00:00:00 2001 From: Alex-larget <33240357+Alex-larget@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:51:12 +0800 Subject: [PATCH] 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. --- miniprogram/app.json | 1 + miniprogram/pages/read/read.js | 35 ++- miniprogram/pages/read/read.wxml | 2 +- miniprogram/project.private.config.json | 11 +- miniprogram/utils/contentParser.js | 8 +- soul-admin/src/components/RichEditor.tsx | 8 + soul-admin/src/pages/content/ContentPage.tsx | 109 ++++++- .../src/pages/linked-mp/LinkedMpPage.tsx | 283 ++++++++++++++++++ .../src/pages/settings/SettingsPage.tsx | 16 +- soul-api/internal/handler/admin_linked_mp.go | 212 +++++++++++++ soul-api/internal/handler/db.go | 36 ++- soul-api/internal/handler/db_link_tag.go | 5 + soul-api/internal/router/router.go | 4 + 13 files changed, 696 insertions(+), 34 deletions(-) create mode 100644 soul-admin/src/pages/linked-mp/LinkedMpPage.tsx create mode 100644 soul-api/internal/handler/admin_linked_mp.go diff --git a/miniprogram/app.json b/miniprogram/app.json index 330b0065..2912c1ff 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -57,6 +57,7 @@ ] }, "usingComponents": {}, + "navigateToMiniProgramAppIdList": [], "__usePrivacyCheck__": true, "lazyCodeLoading": "requiredComponents", "style": "v2", diff --git a/miniprogram/pages/read/read.js b/miniprogram/pages/read/read.js index 6df70dd0..525a229f 100644 --- a/miniprogram/pages/read/read.js +++ b/miniprogram/pages/read/read.js @@ -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() // 旧格式()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) { diff --git a/miniprogram/pages/read/read.wxml b/miniprogram/pages/read/read.wxml index 9452353c..4c2c68cb 100644 --- a/miniprogram/pages/read/read.wxml +++ b/miniprogram/pages/read/read.wxml @@ -48,7 +48,7 @@ {{seg.text}} @{{seg.nickname}} - #{{seg.label}} + #{{seg.label}} diff --git a/miniprogram/project.private.config.json b/miniprogram/project.private.config.json index 964ea715..a4caf5f6 100644 --- a/miniprogram/project.private.config.json +++ b/miniprogram/project.private.config.json @@ -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", diff --git a/miniprogram/utils/contentParser.js b/miniprogram/utils/contentParser.js index 5bf2d869..37358ab5 100644 --- a/miniprogram/utils/contentParser.js +++ b/miniprogram/utils/contentParser.js @@ -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 (/^(insertLinkTag 旧版产生,url 可能为空) diff --git a/soul-admin/src/components/RichEditor.tsx b/soul-admin/src/components/RichEditor.tsx index f821650e..2f621ad7 100644 --- a/soul-admin/src/components/RichEditor.tsx +++ b/soul-admin/src/components/RichEditor.tsx @@ -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(({ tagType: tag.type || 'url', tagId: tag.id || '', pagePath: tag.pagePath || '', + appId: tag.appId || '', + mpKey: tag.type === 'miniprogram' ? (tag.appId || '') : '', }, }).run() }, [editor]) diff --git a/soul-admin/src/pages/content/ContentPage.tsx b/soul-admin/src/pages/content/ContentPage.tsx index db04c67e..eb3be612 100644 --- a/soul-admin/src/pages/content/ContentPage.tsx +++ b/soul-admin/src/pages/content/ContentPage.tsx @@ -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(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() {
-
+
setNewLinkTag({ ...newLinkTag, tagId: e.target.value })} />
-
+
setNewLinkTag({ ...newLinkTag, label: e.target.value })} />
-
+
-
+
- { - 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 ? ( +
+ { + 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 && ( +
+ {filteredLinkedMps.length === 0 ? ( +
无匹配,可手动输入密钥
+ ) : ( + filteredLinkedMps.map((m) => ( + + )) + )} +
+ )} +
+ ) : ( + { + if (newLinkTag.type === 'url' || newLinkTag.type === 'ckb') setNewLinkTag({ ...newLinkTag, url: e.target.value }) + else setNewLinkTag({ ...newLinkTag, appId: e.target.value }) + }} + /> + )}
{newLinkTag.type === 'miniprogram' && ( -
+
+ {loading ? ( +
加载中...
+ ) : ( + + + + 名称 + 密钥 + AppID + 路径 + 排序 + 操作 + + + + {list.map((item) => ( + + {item.name} + {item.key || item.id || '—'} + {item.appId} + {item.path || '—'} + {item.sort ?? 0} + +
+ + +
+
+
+ ))} + {list.length === 0 && ( + + + 暂无关联小程序,点击「添加关联小程序」开始配置 + + + )} +
+
+ )} + + + + + + + {editing ? '编辑关联小程序' : '添加关联小程序'} + + 填写目标小程序的名称和 AppID,路径可选(为空则打开首页) + + +
+
+ + setForm((p) => ({ ...p, name: e.target.value }))} + /> +
+
+ + setForm((p) => ({ ...p, appId: e.target.value }))} + /> +
+
+ + setForm((p) => ({ ...p, path: e.target.value }))} + /> +
+
+ + + setForm((p) => ({ ...p, sort: parseInt(e.target.value, 10) || 0 })) + } + /> +
+
+ + + + +
+
+
+ ) +} diff --git a/soul-admin/src/pages/settings/SettingsPage.tsx b/soul-admin/src/pages/settings/SettingsPage.tsx index d112f41e..361d29cd 100644 --- a/soul-admin/src/pages/settings/SettingsPage.tsx +++ b/soul-admin/src/pages/settings/SettingsPage.tsx @@ -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() { 作者详情 + + + 关联小程序 + + + + + diff --git a/soul-api/internal/handler/admin_linked_mp.go b/soul-api/internal/handler/admin_linked_mp.go new file mode 100644 index 00000000..4691ce23 --- /dev/null +++ b/soul-api/internal/handler/admin_linked_mp.go @@ -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 +} diff --git a/soul-api/internal/handler/db.go b/soul-api/internal/handler/db.go index d1e3a3c7..cbfe5ed2 100644 --- a/soul-api/internal/handler/db.go +++ b/soul-api/internal/handler/db.go @@ -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) } diff --git a/soul-api/internal/handler/db_link_tag.go b/soul-api/internal/handler/db_link_tag.go index e45c9334..ca756e5f 100644 --- a/soul-api/internal/handler/db_link_tag.go +++ b/soul-api/internal/handler/db_link_tag.go @@ -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()}) diff --git a/soul-api/internal/router/router.go b/soul-api/internal/router/router.go index 16de24fc..aa13a309 100644 --- a/soul-api/internal/router/router.go +++ b/soul-api/internal/router/router.go @@ -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)