diff --git a/hunliji-api/hl-api b/hunliji-api/hl-api
index 16cc154..d7d8ddb 100644
Binary files a/hunliji-api/hl-api and b/hunliji-api/hl-api differ
diff --git a/hunliji-api/hunliji-api.exe b/hunliji-api/hunliji-api.exe
index e9b9b1e..2dcd012 100644
Binary files a/hunliji-api/hunliji-api.exe and b/hunliji-api/hunliji-api.exe differ
diff --git a/hunliji-api/main.go b/hunliji-api/main.go
index cb24c20..bbe5810 100644
--- a/hunliji-api/main.go
+++ b/hunliji-api/main.go
@@ -5,6 +5,7 @@ import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
+ "encoding/hex"
"encoding/json"
"fmt"
"log"
@@ -79,6 +80,41 @@ type SiteLike struct {
func (SiteLike) TableName() string { return "site_likes" }
+// AiModerationCache 缓存 AI 审核通过/失败结果,命中则跳过星火调用
+type AiModerationCache struct {
+ ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
+ Content string `gorm:"column:content;type:text;not null;comment:姓名+祝福原文" json:"content"`
+ ContentHash string `gorm:"column:content_hash;size:64;not null;index:idx_ai_mod_type_hash,priority:2;comment:content的SHA256" json:"content_hash"`
+ Type string `gorm:"column:type;size:20;not null;index:idx_ai_mod_type_hash,priority:1;comment:类型 danmaku|rsvp" json:"type"`
+ Status string `gorm:"column:status;size:20;not null;default:rejected;comment:审核状态 approved|rejected" json:"status"`
+ Reason string `gorm:"column:reason;size:255;not null;default:'';comment:拒绝原因" json:"reason"`
+ CreatedAt time.Time `gorm:"column:created_at;not null;comment:创建时间" json:"created_at"`
+}
+
+func (AiModerationCache) TableName() string { return "ai_moderation_caches" }
+
+func moderationCacheContent(name, content string) string {
+ return strings.TrimSpace(name) + "\n" + strings.TrimSpace(content)
+}
+
+func hashModerationContent(content string) string {
+ sum := sha256.Sum256([]byte(content))
+ return hex.EncodeToString(sum[:])
+}
+
+func saveModerationCache(typ, content, status, reason string) {
+ row := AiModerationCache{
+ Content: content,
+ ContentHash: hashModerationContent(content),
+ Type: typ,
+ Status: status,
+ Reason: reason,
+ }
+ if err := db.Create(&row).Error; err != nil {
+ log.Printf("写入审核缓存失败: %v", err)
+ }
+}
+
var danmakuColorWhitelist = map[string]struct{}{
"champagne": {},
"blush": {},
@@ -111,16 +147,48 @@ var (
adminSecret = getEnv("ADMIN_SECRET", "wedding-admin-secret-2026")
)
-func moderateOrPass(name, content string) (bool, string) {
+func moderateOrPass(name, content, typ string) (bool, string) {
+ guest := strings.TrimSpace(name)
+ body := strings.TrimSpace(content)
+ if body == "" {
+ return true, ""
+ }
+ cacheKey := moderationCacheContent(guest, body)
+ cacheHash := hashModerationContent(cacheKey)
+
+ var cached AiModerationCache
+ err := db.Where("type = ? AND content_hash = ?", typ, cacheHash).
+ Order("id desc").
+ First(&cached).Error
+ if err == nil {
+ if cached.Status == "approved" {
+ return true, ""
+ }
+ reason := strings.TrimSpace(cached.Reason)
+ if reason == "" {
+ reason = "内容未通过审核"
+ }
+ return false, reason
+ }
+
if sparkClient == nil || !sparkClient.Enabled() {
return true, ""
}
- ok, reason, err := sparkClient.ModerateText(name, content)
+
+ ok, reason, err := sparkClient.ModerateText(guest, body)
if err != nil {
log.Printf("AI 审核失败,放行: %v", err)
return true, ""
}
- return ok, reason
+ if !ok {
+ if strings.TrimSpace(reason) == "" {
+ reason = "内容未通过审核"
+ }
+ saveModerationCache(typ, cacheKey, "rejected", reason)
+ return false, reason
+ }
+ saveModerationCache(typ, cacheKey, "approved", "")
+ return true, ""
}
func getEnv(k, def string) string {
@@ -199,6 +267,7 @@ func migrateSchema(db *gorm.DB) {
{&Rsvp{}, "出席回执", "rsvps"},
{&Danmaku{}, "祝福弹幕", "danmakus"},
{&SiteLike{}, "站点点赞", "site_likes"},
+ {&AiModerationCache{}, "AI审核缓存", "ai_moderation_caches"},
}
for _, t := range tables {
opts := fmt.Sprintf("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='%s'", escapeMySQLComment(t.comment))
@@ -595,7 +664,7 @@ func main() {
wishes := strings.TrimSpace(rsvp.Wishes)
if wishes != "" {
- ok, reason := moderateOrPass(rsvp.Name, wishes)
+ ok, reason := moderateOrPass(rsvp.Name, wishes, "rsvp")
if !ok {
c.JSON(400, gin.H{"error": "内容未通过审核:" + reason})
return
@@ -656,7 +725,7 @@ func main() {
c.JSON(400, gin.H{"error": "姓名过长"})
return
}
- ok, reason := moderateOrPass(name, content)
+ ok, reason := moderateOrPass(name, content, "danmaku")
if !ok {
c.JSON(400, gin.H{"error": "内容未通过审核:" + reason})
return
diff --git a/vite-tailwindcss/index.html b/vite-tailwindcss/index.html
index 13ddbb6..8b64aea 100644
--- a/vite-tailwindcss/index.html
+++ b/vite-tailwindcss/index.html
@@ -11,7 +11,7 @@
-
李琦&李逸烁的婚礼请柬
+ 💕李琦❤️李逸烁-💒婚礼请柬
diff --git a/vite-tailwindcss/src/App.vue b/vite-tailwindcss/src/App.vue
index a8c549f..a7b03f6 100644
--- a/vite-tailwindcss/src/App.vue
+++ b/vite-tailwindcss/src/App.vue
@@ -1,9 +1,11 @@
+
+
+
diff --git a/vite-tailwindcss/src/components/DanmakuLayer.vue b/vite-tailwindcss/src/components/DanmakuLayer.vue
index ad2435a..cd131ff 100644
--- a/vite-tailwindcss/src/components/DanmakuLayer.vue
+++ b/vite-tailwindcss/src/components/DanmakuLayer.vue
@@ -77,12 +77,17 @@ const buildRow = (item, { force = false } = {}) => {
const delay = force ? 0 : Math.random() * 0.4
const id = ++uid
+ // 发送者刚插入的弹幕强制「刚刚」,避免服务端时间解析偏差
+ const timeLabel = force
+ ? '刚刚'
+ : formatRelativeTime(item.created_at || Date.now())
+
return {
id,
name: item.name,
content: item.content,
color: resolveDanmakuColor(item.color),
- timeLabel: formatRelativeTime(item.created_at || Date.now()),
+ timeLabel,
style: {
top: `${8 + track * 12}%`,
animationDuration: `${duration}s`,
@@ -231,9 +236,9 @@ defineExpose({ refresh: fetchList, pushItem })
animation-timing-function: linear;
animation-fill-mode: forwards;
backdrop-filter: blur(6px);
- max-width: min(80vw, 420px);
- overflow: hidden;
- text-overflow: ellipsis;
+ /* 不裁切宽度:长祝福时末尾相对时间需完整露出并随整条飞过 */
+ width: max-content;
+ max-width: none;
}
.danmaku-name { font-weight: 600; }
.danmaku-sep { opacity: 0.9; }
diff --git a/vite-tailwindcss/src/components/PhotoGallery.vue b/vite-tailwindcss/src/components/PhotoGallery.vue
index c5edfd8..c5922ea 100644
--- a/vite-tailwindcss/src/components/PhotoGallery.vue
+++ b/vite-tailwindcss/src/components/PhotoGallery.vue
@@ -276,7 +276,14 @@ function getRevealConfig(imgIdx) {
box-shadow: none !important;
}
-.overlap-wrap { height: clamp(320px, 54vh, 460px); }
+.overlap-wrap {
+ width: 100%;
+ max-width: 420px;
+ margin-inline: auto;
+ height: clamp(360px, 56vh, 520px);
+ padding: 12px 16px;
+ box-sizing: border-box;
+}
.g-masonry { column-count: 2; column-gap: 10px; }
.g-masonry-item { break-inside: avoid; margin-bottom: 10px; border-radius: 6px; }
diff --git a/vite-tailwindcss/src/composables/useToast.js b/vite-tailwindcss/src/composables/useToast.js
new file mode 100644
index 0000000..4c3661f
--- /dev/null
+++ b/vite-tailwindcss/src/composables/useToast.js
@@ -0,0 +1,36 @@
+import { ref } from 'vue'
+
+const toasts = ref([])
+let seq = 0
+
+function dismiss(id) {
+ toasts.value = toasts.value.filter((t) => t.id !== id)
+}
+
+function show(message, type = 'info', duration = 2400) {
+ const text = String(message || '').trim()
+ if (!text) return
+ const id = ++seq
+ toasts.value = [...toasts.value, { id, message: text, type }]
+ if (duration > 0) {
+ setTimeout(() => dismiss(id), duration)
+ }
+ return id
+}
+
+export function useToast() {
+ return {
+ toasts,
+ dismiss,
+ info: (msg, duration) => show(msg, 'info', duration),
+ success: (msg, duration) => show(msg, 'success', duration),
+ error: (msg, duration) => show(msg, 'error', duration),
+ }
+}
+
+export const toast = {
+ info: (msg, duration) => show(msg, 'info', duration),
+ success: (msg, duration) => show(msg, 'success', duration),
+ error: (msg, duration) => show(msg, 'error', duration),
+ dismiss,
+}
diff --git a/vite-tailwindcss/src/utils/relativeTime.js b/vite-tailwindcss/src/utils/relativeTime.js
index e9d3be3..f858349 100644
--- a/vite-tailwindcss/src/utils/relativeTime.js
+++ b/vite-tailwindcss/src/utils/relativeTime.js
@@ -9,12 +9,33 @@ function startOfWeekMonday(d) {
return x
}
+/** Parse API times; tolerate Go nano fractions and naive datetime strings. */
+function parseTime(input) {
+ if (input instanceof Date) return input
+ if (typeof input === 'number') return new Date(input)
+ if (typeof input !== 'string') return new Date(input)
+
+ let s = input.trim()
+ if (!s) return new Date(NaN)
+
+ // Go RFC3339 with >3 fractional digits can break some engines — keep ms only
+ s = s.replace(/(\.\d{3})\d+(?=[Zz]|[+-]\d{2}:?\d{2}$)/, '$1')
+
+ // Naive "YYYY-MM-DD HH:mm:ss" → treat as local
+ if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(s)) {
+ s = s.replace(' ', 'T')
+ }
+
+ const d = new Date(s)
+ return d
+}
+
/**
* Chinese relative time for danmaku:
* 刚刚 | x分钟前 | x小时前 | x天前 | 周X | 上周X | x周前 | X月X日 | X年X月X日
*/
export function formatRelativeTime(input, now = new Date()) {
- const date = input instanceof Date ? input : new Date(input)
+ const date = parseTime(input)
if (Number.isNaN(date.getTime())) return ''
const diffMs = now.getTime() - date.getTime()
diff --git a/vite-tailwindcss/src/views/index/index.vue b/vite-tailwindcss/src/views/index/index.vue
index 9a785b9..03a0d56 100644
--- a/vite-tailwindcss/src/views/index/index.vue
+++ b/vite-tailwindcss/src/views/index/index.vue
@@ -462,6 +462,7 @@
import { ref, reactive, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { getAlbumImages, getConfig, submitRsvp, submitDanmaku, generateAiBlessing, getLikeStatus, submitLike } from '@/api/wedding'
import { useAudio } from '@/composables/useAudio'
+import { toast } from '@/composables/useToast'
import { pickMusicUrl, rememberMusicPick } from '@/utils/musicPick'
import { getClientId } from '@/utils/clientId'
import { DANMAKU_COLORS, DEFAULT_DANMAKU_COLOR, BLESSING_STYLES } from '@/utils/danmakuColors'
@@ -806,11 +807,20 @@ const scheduleAutoScrollStart = () => {
scrollStartTimer = setTimeout(() => startAutoScroll(), firstScreenDuration.value)
}
+const scrollPageToTop = () => {
+ window.scrollTo(0, 0)
+ document.documentElement.scrollTop = 0
+ document.body.scrollTop = 0
+}
+
const completeIntro = () => {
if (!showIntro.value) return
clearTimeout(introTimer)
+ // 揭幕结束先回顶,避免落点仍在下方控件上造成误触
+ scrollPageToTop()
showIntro.value = false
- startAutoScroll()
+ introOpening.value = false
+ scheduleAutoScrollStart()
}
const toggleAutoScroll = () => {
@@ -822,6 +832,7 @@ const toggleAutoScroll = () => {
const openInvitation = () => {
if (introOpening.value) return
clearTimeout(introTimer)
+ scrollPageToTop()
introOpening.value = true
introTimer = setTimeout(() => completeIntro(), introExitDuration.value)
if (data.value.musicList.length) audio.play()
@@ -894,7 +905,7 @@ const pickAiStyle = async (target, style) => {
if (target === 'rsvp') rsvpForm.wishes = text
else blessingForm.content = text
} catch (e) {
- alert(e.message || 'AI 生成失败')
+ toast.error(e.message || 'AI 生成失败')
} finally {
aiBlessingLoading.value = false
aiBlessingTarget.value = ''
@@ -902,20 +913,20 @@ const pickAiStyle = async (target, style) => {
}
const handleSubmitRsvp = async () => {
- if (!rsvpForm.name.trim()) return alert('请填写姓名')
+ if (!rsvpForm.name.trim()) return toast.error('请填写姓名')
try {
const res = await submitRsvp(rsvpForm)
if (res.code === 200) {
if (res.danmaku) danmakuLayerRef.value?.pushItem?.(res.danmaku)
isSubmitted.value = true
}
- else alert(res.error || '提交失败')
- } catch (e) { alert(e.message || '提交失败,请检查后端运行状态') }
+ else toast.error(res.error || '提交失败')
+ } catch (e) { toast.error(e.message || '提交失败,请检查后端运行状态') }
}
const handleSubmitBlessing = async () => {
- if (!blessingForm.name.trim()) return alert('请填写姓名')
- if (!blessingForm.content.trim()) return alert('请填写祝福')
+ if (!blessingForm.name.trim()) return toast.error('请填写姓名')
+ if (!blessingForm.content.trim()) return toast.error('请填写祝福')
try {
const res = await submitDanmaku({
name: blessingForm.name.trim(),
@@ -926,9 +937,9 @@ const handleSubmitBlessing = async () => {
if (res.data) danmakuLayerRef.value?.pushItem?.(res.data)
blessingSubmitted.value = true
}
- else alert(res.error || '提交失败')
+ else toast.error(res.error || '提交失败')
} catch (e) {
- alert(e.message || '提交失败,请检查后端运行状态')
+ toast.error(e.message || '提交失败,请检查后端运行状态')
}
}
@@ -950,7 +961,7 @@ const handleLike = async () => {
likePulseTimer = setTimeout(() => { likePulse.value = false }, 600)
likeBurstRef.value?.burst(3)
} catch (e) {
- alert(e.message || '点赞失败')
+ toast.error(e.message || '点赞失败')
} finally {
likeBusy.value = false
}