feat(admin): 富文本与内容管理页调整;链接标签模型与 db 同步;更新 dist

Made-with: Cursor
This commit is contained in:
卡若
2026-03-24 12:29:46 +08:00
parent 70b83fdb25
commit f069b71374
8 changed files with 272 additions and 153 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台 - Soul创业派对</title>
<script type="module" crossorigin src="/assets/index-Dv-LWSbq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DXojA1Za.css">
<script type="module" crossorigin src="/assets/index-Dz0mx7au.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-qjssBjc3.css">
</head>
<body>
<div id="root"></div>

View File

@@ -37,9 +37,11 @@ export interface LinkTagItem {
label: string
aliases?: string
url: string
type: 'url' | 'miniprogram' | 'ckb'
type: 'url' | 'miniprogram' | 'ckb' | 'wxlink'
appId?: string
pagePath?: string
/** 管理端列表用:库内是否已存目标小程序 AppSecret接口不下发明文 */
hasAppSecret?: boolean
}
/** 插入附件 HTML 时转义,防 XSS */

View File

@@ -260,8 +260,9 @@ export function ContentPage() {
label: '',
aliases: '',
url: '',
type: 'url' as 'url' | 'miniprogram' | 'ckb',
type: 'url' as 'url' | 'miniprogram' | 'ckb' | 'wxlink',
appId: '',
appSecret: '',
pagePath: '',
})
const [linkTagSaving, setLinkTagSaving] = useState(false)
@@ -537,7 +538,15 @@ export function ContentPage() {
try {
const data = await get<{
success?: boolean
linkTags?: { tagId: string; label: string; url: string; type: string; appId?: string; pagePath?: string }[]
linkTags?: {
tagId: string
label: string
url: string
type: string
appId?: string
pagePath?: string
hasAppSecret?: boolean
}[]
}>('/api/db/link-tags')
if (data?.success && data.linkTags) {
setLinkTags(
@@ -545,9 +554,10 @@ export function ContentPage() {
id: t.tagId,
label: t.label,
url: t.url,
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb',
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb' | 'wxlink',
appId: t.appId || '',
pagePath: t.pagePath || '',
hasAppSecret: !!t.hasAppSecret,
})),
)
}
@@ -608,7 +618,16 @@ export function ContentPage() {
if (s) qs.set('search', s)
const data = await get<{
success?: boolean
linkTags?: { tagId: string; label: string; aliases?: string; url: string; type: string; appId?: string; pagePath?: string }[]
linkTags?: {
tagId: string
label: string
aliases?: string
url: string
type: string
appId?: string
pagePath?: string
hasAppSecret?: boolean
}[]
total?: number
page?: number
pageSize?: number
@@ -622,9 +641,10 @@ export function ContentPage() {
label: t.label,
aliases: t.aliases || '',
url: t.url,
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb',
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb' | 'wxlink',
appId: t.appId || '',
pagePath: t.pagePath || '',
hasAppSecret: !!t.hasAppSecret,
})),
)
setLinkTagTotal(typeof data.total === 'number' ? data.total : 0)
@@ -2810,7 +2830,7 @@ export function ContentPage() {
className="bg-amber-500 hover:bg-amber-600 text-white h-8"
onClick={() => {
setLinkTagEditing(null)
setLinkTagForm({ tagId: '', label: '', aliases: '', url: '', type: 'url', appId: '', pagePath: '' })
setLinkTagForm({ tagId: '', label: '', aliases: '', url: '', type: 'url', appId: '', appSecret: '', pagePath: '' })
setMpSearchQuery('')
setMpDropdownOpen(false)
setLinkTagModalOpen(true)
@@ -2862,6 +2882,7 @@ export function ContentPage() {
url: t.url,
type: t.type,
appId: t.appId ?? '',
appSecret: '',
pagePath: t.pagePath ?? '',
})
setMpSearchQuery(t.appId ?? '')
@@ -2882,12 +2903,12 @@ export function ContentPage() {
className={`text-[10px] ${
t.type === 'ckb'
? 'bg-green-500/20 text-green-300 border-green-500/30'
: t.type === 'miniprogram'
: t.type === 'miniprogram' || t.type === 'wxlink'
? 'bg-[#38bdac]/20 text-[#38bdac] border-[#38bdac]/30'
: 'bg-gray-700 text-gray-300'
}`}
>
{t.type === 'url' ? '网页' : t.type === 'ckb' ? '存客宝' : '小程序'}
{t.type === 'url' ? '网页' : t.type === 'ckb' ? '存客宝' : t.type === 'wxlink' ? '小程序链接' : '小程序'}
</Badge>
</td>
<td className="px-3 py-2 text-gray-300">
@@ -2903,6 +2924,18 @@ export function ContentPage() {
)
})()}
{t.pagePath && <div className="text-xs text-gray-500 font-mono">{t.pagePath}</div>}
<div
className={`text-xs ${t.hasAppSecret ? 'text-emerald-400/90' : 'text-amber-500/80'}`}
>
AppSecret{t.hasAppSecret ? '已保存(仅服务端)' : '未配置'}
</div>
</div>
) : t.type === 'wxlink' ? (
<div className="space-y-0.5">
<div className="text-xs text-[#38bdac] truncate max-w-[420px] font-mono" title={t.url}>
{t.url || '—'}
</div>
<div className="text-[11px] text-gray-500"> web-view </div>
</div>
) : t.url ? (
<a
@@ -2932,6 +2965,7 @@ export function ContentPage() {
url: t.url,
type: t.type,
appId: t.appId ?? '',
appSecret: '',
pagePath: t.pagePath ?? '',
})
setMpSearchQuery(t.appId ?? '')
@@ -2996,7 +3030,7 @@ export function ContentPage() {
<DialogHeader className="gap-1">
<DialogTitle className="text-base">{linkTagEditing ? '编辑链接标签' : '添加链接标签'}</DialogTitle>
<DialogDescription className="text-gray-400 text-xs">
#
# mpKey AppIDAppSecret 使
</DialogDescription>
</DialogHeader>
@@ -3006,7 +3040,7 @@ export function ContentPage() {
<Label className="text-gray-300 text-sm">ID</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm font-mono"
placeholder="留空自动生成;或填 12位数字 / z开头12位"
placeholder="留空自动生成;或自定义短 ID如 kr最长 50 字符"
value={linkTagForm.tagId}
disabled={!!linkTagEditing}
onChange={(e) => setLinkTagForm((p) => ({ ...p, tagId: e.target.value }))}
@@ -3038,7 +3072,7 @@ export function ContentPage() {
<Select
value={linkTagForm.type}
onValueChange={(v) =>
setLinkTagForm((p) => ({ ...p, type: v as 'url' | 'miniprogram' | 'ckb' }))
setLinkTagForm((p) => ({ ...p, type: v as 'url' | 'miniprogram' | 'ckb' | 'wxlink' }))
}
>
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white h-8">
@@ -3046,7 +3080,8 @@ export function ContentPage() {
</SelectTrigger>
<SelectContent className="bg-[#0f2137] border-gray-700 text-white">
<SelectItem value="url"></SelectItem>
<SelectItem value="miniprogram"></SelectItem>
<SelectItem value="miniprogram">API跳转</SelectItem>
<SelectItem value="wxlink"></SelectItem>
<SelectItem value="ckb"></SelectItem>
</SelectContent>
</Select>
@@ -3057,9 +3092,18 @@ export function ContentPage() {
? 'URL地址'
: linkTagForm.type === 'ckb'
? '存客宝计划URL'
: '小程序(选密钥)'}
: linkTagForm.type === 'wxlink'
? '小程序链接'
: '小程序 mpKey / 微信 AppID'}
</Label>
{linkTagForm.type === 'miniprogram' && linkedMps.length > 0 ? (
{linkTagForm.type === 'wxlink' ? (
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm"
placeholder="粘贴小程序右上角 ... → 复制链接 得到的 URL"
value={linkTagForm.url}
onChange={(e) => setLinkTagForm((p) => ({ ...p, url: e.target.value }))}
/>
) : linkTagForm.type === 'miniprogram' && linkedMps.length > 0 ? (
<div ref={mpDropdownRef} className="relative">
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm"
@@ -3110,7 +3154,7 @@ export function ContentPage() {
? 'https://...'
: linkTagForm.type === 'ckb'
? 'https://ckbapi.quwanzhi.com/...'
: '关联小程序的32位密钥'
: '关联配置的 key或直接填 wx 开头的 AppID'
}
value={linkTagForm.type === 'url' || linkTagForm.type === 'ckb' ? linkTagForm.url : linkTagForm.appId}
onChange={(e) => {
@@ -3123,16 +3167,38 @@ export function ContentPage() {
</div>
</div>
{linkTagForm.type === 'wxlink' && (
<p className="text-[11px] text-amber-400/80 leading-snug px-0.5">
... web-view
</p>
)}
{linkTagForm.type === 'miniprogram' && (
<div className="space-y-1">
<Label className="text-gray-300 text-sm"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm font-mono"
placeholder="pages/index/index"
value={linkTagForm.pagePath}
onChange={(e) => setLinkTagForm((p) => ({ ...p, pagePath: e.target.value }))}
/>
</div>
<>
<div className="space-y-1">
<Label className="text-gray-300 text-sm"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm font-mono"
placeholder="pages/index/index"
value={linkTagForm.pagePath}
onChange={(e) => setLinkTagForm((p) => ({ ...p, pagePath: e.target.value }))}
/>
</div>
<div className="space-y-1">
<Label className="text-gray-300 text-sm">AppSecret · </Label>
<Input
type="password"
autoComplete="new-password"
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm font-mono"
placeholder={linkTagEditing?.hasAppSecret ? '已保存密钥,留空不改;填写则覆盖' : '粘贴目标小程序 AppSecret'}
value={linkTagForm.appSecret}
onChange={(e) => setLinkTagForm((p) => ({ ...p, appSecret: e.target.value }))}
/>
<p className="text-[11px] text-gray-500 leading-snug">
AppID
</p>
</div>
</>
)}
</div>
@@ -3149,13 +3215,18 @@ export function ContentPage() {
url: linkTagForm.url.trim(),
type: linkTagForm.type,
appId: linkTagForm.appId.trim(),
appSecret: linkTagForm.appSecret.trim(),
pagePath: linkTagForm.pagePath.trim(),
}
// 新增:允许留空后端自动生成;编辑tagId 已锁定
// 留空后端自动生成;自定义时与库一致150 字符,勿含 #、逗号、换行
if (payload.tagId) {
const ok = /^\d{12}$/.test(payload.tagId) || /^z[a-z0-9]{11}$/.test(payload.tagId)
if (!ok) {
toast.error('标签ID需为12位数字或 z 开头的12位z+11位小写字母数字')
const id = payload.tagId
if ([...id].length > 50) {
toast.error('标签ID 最长 50 个字符')
return
}
if (/[#,\n\r\t]/.test(id)) {
toast.error('标签ID 不能含 #、逗号或换行')
return
}
}
@@ -3164,6 +3235,7 @@ export function ContentPage() {
return
}
if (payload.type === 'miniprogram') payload.url = ''
if (payload.type === 'wxlink') { payload.appId = ''; payload.pagePath = '' }
setLinkTagSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/db/link-tags', payload)

View File

@@ -317,9 +317,18 @@ func buildMiniprogramConfig() gin.H {
_ = db.Order("label ASC").Find(&linkTagRows).Error
tags := make([]gin.H, 0, len(linkTagRows))
for _, t := range linkTagRows {
h := gin.H{"tagId": t.TagID, "label": t.Label, "url": t.URL, "type": t.Type, "pagePath": t.PagePath}
cType := t.Type
cURL := t.URL
// wxlink小程序短链下发给 C 端时转为 url 类型,现有 read.js web-view 可直接跳转,无需升级小程序
if strings.EqualFold(cType, "wxlink") {
cType = "url"
if cURL == "" {
cURL = t.AppID
}
}
h := gin.H{"tagId": t.TagID, "label": t.Label, "url": cURL, "type": cType, "pagePath": t.PagePath}
if t.Type == "miniprogram" {
h["mpKey"] = t.AppID // 可为「关联表 key」或「直接 wx AppID」后者由 mergeDirectMiniProgramLinksFromLinkTags 补全 linkedMiniprograms
h["mpKey"] = t.AppID
} else {
h["appId"] = t.AppID
}

View File

@@ -6,6 +6,7 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
"soul-api/internal/cache"
"soul-api/internal/database"
@@ -14,30 +15,64 @@ import (
"github.com/gin-gonic/gin"
)
func isDigits12(s string) bool {
if len(s) != 12 {
// isValidLinkTagID 自定义 tagId与库字段 tag_id(50) 对齐,禁止破坏 #标签 解析的字符
func isValidLinkTagID(s string) bool {
if s == "" {
return false
}
for _, ch := range s {
if ch < '0' || ch > '9' {
if utf8.RuneCountInString(s) > 50 {
return false
}
for _, r := range s {
if r == '#' || r == ',' || r == '\n' || r == '\r' || r == '\t' {
return false
}
}
return true
}
func isZId12(s string) bool {
if len(s) != 12 || s[0] != 'z' {
return false
// linkTagAdminOut 管理端 API 出参:不含 appSecret 明文,仅 hasAppSecret
type linkTagAdminOut struct {
ID uint `json:"id"`
TagID string `json:"tagId"`
Label string `json:"label"`
Aliases string `json:"aliases"`
URL string `json:"url"`
Type string `json:"type"`
AppID string `json:"appId,omitempty"`
PagePath string `json:"pagePath,omitempty"`
HasAppSecret bool `json:"hasAppSecret"`
CreatedAt string `json:"createdAt,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
func linkTagToAdminOut(t model.LinkTag) linkTagAdminOut {
o := linkTagAdminOut{
ID: t.ID,
TagID: t.TagID,
Label: t.Label,
Aliases: t.Aliases,
URL: t.URL,
Type: t.Type,
AppID: t.AppID,
PagePath: t.PagePath,
HasAppSecret: strings.TrimSpace(t.AppSecret) != "",
}
for i := 1; i < 12; i++ {
ch := s[i]
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') {
continue
}
return false
if !t.CreatedAt.IsZero() {
o.CreatedAt = t.CreatedAt.Format(time.RFC3339)
}
return true
if !t.UpdatedAt.IsZero() {
o.UpdatedAt = t.UpdatedAt.Format(time.RFC3339)
}
return o
}
func linkTagsToAdminOut(rows []model.LinkTag) []linkTagAdminOut {
out := make([]linkTagAdminOut, 0, len(rows))
for _, t := range rows {
out = append(out, linkTagToAdminOut(t))
}
return out
}
func genZId12() string {
@@ -63,7 +98,7 @@ func DBLinkTagList(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "linkTags": rows})
c.JSON(http.StatusOK, gin.H{"success": true, "linkTags": linkTagsToAdminOut(rows)})
return
}
@@ -99,7 +134,7 @@ func DBLinkTagList(c *gin.Context) {
totalPages := (int(total) + pageSize - 1) / pageSize
c.JSON(http.StatusOK, gin.H{
"success": true,
"linkTags": rows,
"linkTags": linkTagsToAdminOut(rows),
"total": total,
"page": page,
"pageSize": pageSize,
@@ -110,13 +145,14 @@ func DBLinkTagList(c *gin.Context) {
// DBLinkTagSave POST /api/db/link-tags 管理端-新增或更新链接标签
func DBLinkTagSave(c *gin.Context) {
var body struct {
TagID string `json:"tagId"`
Label string `json:"label"`
Aliases string `json:"aliases"`
URL string `json:"url"`
Type string `json:"type"`
AppID string `json:"appId"`
PagePath string `json:"pagePath"`
TagID string `json:"tagId"`
Label string `json:"label"`
Aliases string `json:"aliases"`
URL string `json:"url"`
Type string `json:"type"`
AppID string `json:"appId"`
AppSecret string `json:"appSecret"`
PagePath string `json:"pagePath"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
@@ -128,6 +164,7 @@ func DBLinkTagSave(c *gin.Context) {
body.URL = strings.TrimSpace(body.URL)
body.Type = strings.TrimSpace(body.Type)
body.AppID = strings.TrimSpace(body.AppID)
body.AppSecret = strings.TrimSpace(body.AppSecret)
body.PagePath = strings.TrimSpace(body.PagePath)
if body.Label == "" {
@@ -141,24 +178,18 @@ func DBLinkTagSave(c *gin.Context) {
if body.Type == "" {
body.Type = "url"
}
// tagId 规则12位数字或 12位且以 z 开头z + 11位[a-z0-9]
// 管理端新增:可不传 tagId由后端生成编辑通常会携带现有 tagId
// 管理端新增:可不传 tagId由后端生成 z 开头 12 位;也可自定义任意 150 字符(如 kr
if body.TagID == "" {
body.TagID = genZId12()
autoCreate = true
}
if !isValidLinkTagID(body.TagID) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "tagId 长度 150 字符,且不能含 #、逗号或换行"})
return
}
db := database.DB()
var existing model.LinkTag
// 若 tagId 不符合新规则:仅允许更新已有记录(兼容历史中文 tagId禁止新建
if !(isDigits12(body.TagID) || isZId12(body.TagID)) {
if err := db.Where("tag_id = ?", body.TagID).First(&existing).Error; err == nil {
// allow update existing legacy tagId
} else {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "tagId 必须为12位数字或12位且以 z 开头z+11位小写字母数字"})
return
}
}
// 小程序类型:只存 appId + pagePath不存 weixin:// 到 url
if body.Type == "miniprogram" {
body.URL = ""
@@ -166,7 +197,7 @@ func DBLinkTagSave(c *gin.Context) {
// 按 label 查找仅用于「自动创建」场景tagId 为空时回落 label若已存在则直接返回
if autoCreate {
if db.Where("label = ?", body.Label).First(&existing).Error == nil {
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": existing})
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": linkTagToAdminOut(existing)})
return
}
}
@@ -176,21 +207,24 @@ func DBLinkTagSave(c *gin.Context) {
existing.URL = body.URL
existing.Type = body.Type
existing.AppID = body.AppID
if body.AppSecret != "" {
existing.AppSecret = body.AppSecret
}
existing.PagePath = body.PagePath
db.Save(&existing)
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": existing})
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": linkTagToAdminOut(existing)})
return
}
// body.URL 已在 miniprogram 类型时置空
t := model.LinkTag{TagID: body.TagID, Label: body.Label, Aliases: body.Aliases, URL: body.URL, Type: body.Type, AppID: body.AppID, PagePath: body.PagePath}
t := model.LinkTag{TagID: body.TagID, Label: body.Label, Aliases: body.Aliases, URL: body.URL, Type: body.Type, AppID: body.AppID, AppSecret: body.AppSecret, PagePath: body.PagePath}
if err := db.Create(&t).Error; err != nil {
// 极低概率:生成的 tagId 冲突,重试一次
if strings.Contains(err.Error(), "Duplicate") || strings.Contains(err.Error(), "1062") {
t.TagID = genZId12()
if e2 := db.Create(&t).Error; e2 == nil {
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": t})
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": linkTagToAdminOut(t)})
return
}
}
@@ -198,7 +232,7 @@ func DBLinkTagSave(c *gin.Context) {
return
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": t})
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": linkTagToAdminOut(t)})
}
// DBLinkTagDelete DELETE /api/db/link-tags?tagId=xxx 管理端-删除链接标签

View File

@@ -11,6 +11,8 @@ type LinkTag struct {
URL string `gorm:"column:url;size:500" json:"url"`
Type string `gorm:"column:type;size:20" json:"type"`
AppID string `gorm:"column:app_id;size:100" json:"appId,omitempty"`
// AppSecret 目标小程序 AppSecret仅存库列表/保存响应用 hasAppSecret永不 json 明文下发
AppSecret string `gorm:"column:app_secret;size:256;default:''" json:"-"`
PagePath string `gorm:"column:page_path;size:500" json:"pagePath,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`