1. 弹幕功能
2. 点赞 3. 导航引导 4. ai弹幕、审核
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<meta name="twitter:title" content="李琦&李逸烁的婚礼请柬" />
|
||||
<meta name="twitter:description" content="(公历2026年9月4日)岁在丙午,仲秋之吉,四日佳期。谨订良缘,敢邀台驾。" />
|
||||
<meta property="og:image" content="https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/liqi/20260731/1774761336736_compressed_compressed.webp">
|
||||
<title>李琦&李逸烁的婚礼请柬</title>
|
||||
<title>💕李琦❤️李逸烁-💒婚礼请柬</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<template>
|
||||
<router-view />
|
||||
<AppToast />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 根组件:仅作为路由出口。
|
||||
import AppToast from '@/components/AppToast.vue'
|
||||
// 根组件:路由出口 + 全局轻提示
|
||||
// 首页请柬 -> src/views/index/index.vue
|
||||
// 后台登录 -> src/views/admin/AdminLogin.vue
|
||||
// 内容编辑 -> src/views/admin/AdminEditor.vue
|
||||
|
||||
111
vite-tailwindcss/src/components/AppToast.vue
Normal file
111
vite-tailwindcss/src/components/AppToast.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="app-toast-host" aria-live="polite" aria-relevant="additions">
|
||||
<TransitionGroup name="app-toast">
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
class="app-toast"
|
||||
:class="`is-${t.type}`"
|
||||
role="status"
|
||||
>
|
||||
<span class="app-toast-icon" aria-hidden="true">
|
||||
<i v-if="t.type === 'success'" class="fa-solid fa-check" />
|
||||
<i v-else-if="t.type === 'error'" class="fa-solid fa-exclamation" />
|
||||
<i v-else class="fa-solid fa-info" />
|
||||
</span>
|
||||
<p class="app-toast-msg">{{ t.message }}</p>
|
||||
<button type="button" class="app-toast-close" aria-label="关闭" @click="dismiss(t.id)">✕</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const { toasts, dismiss } = useToast()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-toast-host {
|
||||
position: fixed;
|
||||
top: 18%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: min(86vw, 340px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.app-toast {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(253, 251, 247, 0.96);
|
||||
border: 0.5px solid rgba(168, 140, 107, 0.35);
|
||||
box-shadow: 0 12px 32px rgba(92, 74, 53, 0.16);
|
||||
backdrop-filter: blur(8px);
|
||||
color: #5c4a35;
|
||||
}
|
||||
.app-toast.is-success {
|
||||
border-color: rgba(168, 140, 107, 0.5);
|
||||
}
|
||||
.app-toast.is-error {
|
||||
border-color: rgba(196, 120, 110, 0.55);
|
||||
background: rgba(255, 248, 246, 0.97);
|
||||
}
|
||||
.app-toast-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 1px;
|
||||
font-size: 11px;
|
||||
background: rgba(168, 140, 107, 0.14);
|
||||
color: #a88c6b;
|
||||
}
|
||||
.app-toast.is-error .app-toast-icon {
|
||||
background: rgba(196, 120, 110, 0.16);
|
||||
color: #c4786e;
|
||||
}
|
||||
.app-toast-msg {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
letter-spacing: 0.04em;
|
||||
word-break: break-word;
|
||||
}
|
||||
.app-toast-close {
|
||||
flex: 0 0 auto;
|
||||
margin-top: -2px;
|
||||
padding: 2px 4px;
|
||||
font-size: 12px;
|
||||
color: #9a9084;
|
||||
line-height: 1;
|
||||
}
|
||||
.app-toast-enter-active,
|
||||
.app-toast-leave-active {
|
||||
transition: opacity 0.28s ease, transform 0.28s ease;
|
||||
}
|
||||
.app-toast-enter-from,
|
||||
.app-toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px) scale(0.98);
|
||||
}
|
||||
.app-toast-move {
|
||||
transition: transform 0.28s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
36
vite-tailwindcss/src/composables/useToast.js
Normal file
36
vite-tailwindcss/src/composables/useToast.js
Normal file
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user