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 }