diff --git a/hunliji-api/configsection/section.go b/hunliji-api/configsection/section.go index 4207981..8640617 100644 --- a/hunliji-api/configsection/section.go +++ b/hunliji-api/configsection/section.go @@ -10,7 +10,7 @@ import ( type templateConfig struct { ID uint `gorm:"primaryKey"` - ConfigData string `gorm:"type:json;column:config_data;not null;default:'{}'"` + ConfigData string `gorm:"type:json;column:config_data;not null"` } func (templateConfig) TableName() string { return "template_configs" } @@ -75,7 +75,7 @@ func saveConfigMap(m map[string]interface{}) error { return err } var config templateConfig - db.FirstOrCreate(&config, 1) + db.Attrs(templateConfig{ConfigData: "{}"}).FirstOrCreate(&config, templateConfig{ID: 1}) config.ConfigData = string(jsonBytes) return db.Save(&config).Error } diff --git a/hunliji-api/main.go b/hunliji-api/main.go index 1ecb537..38b223d 100644 --- a/hunliji-api/main.go +++ b/hunliji-api/main.go @@ -29,6 +29,7 @@ func migrateSchema(db *gorm.DB) { name string }{ {&models.TemplateConfig{}, "请柬模板配置", "template_configs"}, + {&models.PosterConfig{}, "邀请函海报配置", "poster_configs"}, {&models.UploadConfig{}, "上传配置", "upload_configs"}, {&models.Rsvp{}, "出席回执", "rsvps"}, {&models.Danmaku{}, "祝福弹幕", "danmakus"}, diff --git a/hunliji-api/models/models.go b/hunliji-api/models/models.go index add44ee..db09459 100644 --- a/hunliji-api/models/models.go +++ b/hunliji-api/models/models.go @@ -3,13 +3,22 @@ package models import "time" type TemplateConfig struct { - ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` - ConfigData string `gorm:"type:json;column:config_data;not null;default:'{}';comment:请柬配置JSON" json:"config_data"` + ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` + // 旧版 MySQL 的 JSON 列不能带 DEFAULT,空值由业务层回落为 {} + ConfigData string `gorm:"type:json;column:config_data;not null;comment:请柬配置JSON" json:"config_data"` UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间" json:"updated_at"` } func (TemplateConfig) TableName() string { return "template_configs" } +type PosterConfig struct { + ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` + ConfigData string `gorm:"type:json;column:config_data;not null;comment:海报配置JSON" json:"config_data"` + UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间" json:"updated_at"` +} + +func (PosterConfig) TableName() string { return "poster_configs" } + type UploadConfig struct { ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` Provider string `gorm:"column:provider;size:20;not null;default:local;comment:上传方式 local|oss" json:"provider"` diff --git a/hunliji-api/posterconfig/poster.go b/hunliji-api/posterconfig/poster.go new file mode 100644 index 0000000..111f5b8 --- /dev/null +++ b/hunliji-api/posterconfig/poster.go @@ -0,0 +1,138 @@ +package posterconfig + +import ( + "encoding/json" + "strings" + + "hunliji-api/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +var ( + db *gorm.DB + requireAdmin func(*gin.Context) bool +) + +// Register 挂载海报配置路由:GET 公开,POST 需管理员 +func Register(api *gin.RouterGroup, database *gorm.DB, adminGuard func(*gin.Context) bool) { + db = database + requireAdmin = adminGuard + api.GET("/poster/config", handleGetPosterConfig) + api.POST("/poster/config", handleSavePosterConfig) +} + +func resolveSide(raw string) string { + v := strings.ToLower(strings.TrimSpace(raw)) + switch v { + case "female", "nv", "f", "bride": + return "female" + case "male", "n", "m", "groom": + return "male" + default: + return "" + } +} + +func isLegacyFlat(m map[string]interface{}) bool { + if m == nil { + return false + } + if _, ok := m["male"]; ok { + return false + } + if _, ok := m["female"]; ok { + return false + } + for _, k := range []string{"title", "lines", "heroImg", "sonName"} { + if _, ok := m[k]; ok { + return true + } + } + return false +} + +func emptyObj() map[string]interface{} { + return map[string]interface{}{} +} + +func asObject(v interface{}) map[string]interface{} { + if m, ok := v.(map[string]interface{}); ok { + return m + } + return emptyObj() +} + +// normalizeBundle 统一为 { male, female };旧扁平配置迁入 male +func normalizeBundle(raw map[string]interface{}) map[string]interface{} { + if raw == nil { + raw = emptyObj() + } + out := map[string]interface{}{ + "male": emptyObj(), + "female": emptyObj(), + } + if isLegacyFlat(raw) { + out["male"] = raw + return out + } + if m := asObject(raw["male"]); len(m) > 0 { + out["male"] = m + } + if f := asObject(raw["female"]); len(f) > 0 { + out["female"] = f + } + return out +} + +func loadBundle() map[string]interface{} { + var config models.PosterConfig + if err := db.First(&config, 1).Error; err != nil { + return normalizeBundle(nil) + } + raw := strings.TrimSpace(config.ConfigData) + if raw == "" { + raw = "{}" + } + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return normalizeBundle(nil) + } + return normalizeBundle(parsed) +} + +func handleGetPosterConfig(c *gin.Context) { + bundle := loadBundle() + side := resolveSide(c.Query("side")) + if side != "" { + c.JSON(200, gin.H{"code": 200, "data": bundle[side]}) + return + } + c.JSON(200, gin.H{"code": 200, "data": bundle}) +} + +func handleSavePosterConfig(c *gin.Context) { + if !requireAdmin(c) { + return + } + var rawBody map[string]interface{} + if err := c.BindJSON(&rawBody); err != nil { + c.JSON(400, gin.H{"error": "无效的JSON数据"}) + return + } + bundle := normalizeBundle(rawBody) + jsonBytes, err := json.Marshal(bundle) + if err != nil { + c.JSON(400, gin.H{"error": "无效的JSON数据"}) + return + } + var config models.PosterConfig + db.Attrs(models.PosterConfig{ConfigData: "{}"}).FirstOrCreate(&config, models.PosterConfig{ID: 1}) + config.ConfigData = string(jsonBytes) + if err := db.Save(&config).Error; err != nil { + c.JSON(500, gin.H{"error": "海报配置保存失败"}) + return + } + c.JSON(200, gin.H{"code": 200, "msg": "海报配置保存成功", "data": bundle}) +} diff --git a/hunliji-api/router/routers.go b/hunliji-api/router/routers.go index 4c3ba13..37c98d8 100644 --- a/hunliji-api/router/routers.go +++ b/hunliji-api/router/routers.go @@ -15,6 +15,7 @@ import ( "hunliji-api/configsection" "hunliji-api/models" + "hunliji-api/posterconfig" "hunliji-api/spark" "hunliji-api/utils" "hunliji-api/visit" @@ -80,6 +81,7 @@ func registerAPI(api *gin.RouterGroup) { visit.Register(api, db, utils.RequireAdmin) configsection.Register(api, db, utils.RequireAdmin) + posterconfig.Register(api, db, utils.RequireAdmin) } func handleAdminLogin(c *gin.Context) { @@ -126,7 +128,7 @@ func handleSaveConfig(c *gin.Context) { } jsonBytes, _ := json.Marshal(rawBody) var config models.TemplateConfig - db.FirstOrCreate(&config, 1) + db.Attrs(models.TemplateConfig{ConfigData: "{}"}).FirstOrCreate(&config, models.TemplateConfig{ID: 1}) config.ConfigData = string(jsonBytes) if err := db.Save(&config).Error; err != nil { c.JSON(500, gin.H{"error": "数据库保存配置失败"}) diff --git a/vite-tailwindcss/package-lock.json b/vite-tailwindcss/package-lock.json index 802b27a..44a0e9b 100644 --- a/vite-tailwindcss/package-lock.json +++ b/vite-tailwindcss/package-lock.json @@ -19,7 +19,9 @@ "ant-design-vue": "^4.1.1", "axios": "^1.6.2", "echarts": "^6.1.0", + "gsap": "^3.15.0", "lucide-vue-next": "^0.507.0", + "motion-v": "^2.3.0", "pinia": "^2.1.7", "swiper": "^11.0.5", "tailwindcss": "^4.1.4", @@ -1529,6 +1531,13 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT", + "peer": true + }, "node_modules/@unhead/dom": { "version": "1.11.20", "resolved": "https://registry.npmjs.org/@unhead/dom/-/dom-1.11.20.tgz", @@ -1717,6 +1726,24 @@ "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", "license": "MIT" }, + "node_modules/@vueuse/core": { + "version": "14.4.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.4.0.tgz", + "integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.4.0", + "@vueuse/shared": "14.4.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "node_modules/@vueuse/head": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@vueuse/head/-/head-2.0.0.tgz", @@ -1732,6 +1759,29 @@ "vue": ">=2.7 || >=3" } }, + "node_modules/@vueuse/metadata": { + "version": "14.4.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.4.0.tgz", + "integrity": "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.4.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.4.0.tgz", + "integrity": "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -2125,6 +2175,39 @@ "node": ">= 6" } }, + "node_modules/framer-motion": { + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz", + "integrity": "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.43.0", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/framer-motion/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2203,6 +2286,12 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/gsap": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", + "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", + "license": "Standard 'no charge' license: https://gsap.com/standard-license." + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2242,6 +2331,12 @@ "node": ">= 0.4" } }, + "node_modules/hey-listen": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", + "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==", + "license": "MIT" + }, "node_modules/hookable": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", @@ -2637,6 +2732,37 @@ "node": ">= 0.6" } }, + "node_modules/motion-dom": { + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz", + "integrity": "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "license": "MIT" + }, + "node_modules/motion-v": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/motion-v/-/motion-v-2.3.0.tgz", + "integrity": "sha512-J0CCfXtICCni9RjotDUBOs57xNpYI9yyBSohEOxaRHrmjwOtlw291fhRu/mdgEdSasys96R028YDDOAtWBbRaA==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.40.0", + "hey-listen": "^1.0.8", + "motion-dom": "^12.40.0", + "motion-utils": "^12.39.0" + }, + "peerDependencies": { + "@vueuse/core": ">=10.0.0", + "vue": ">=3.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/vite-tailwindcss/package.json b/vite-tailwindcss/package.json index b9d58d5..702c7ff 100644 --- a/vite-tailwindcss/package.json +++ b/vite-tailwindcss/package.json @@ -20,7 +20,9 @@ "ant-design-vue": "^4.1.1", "axios": "^1.6.2", "echarts": "^6.1.0", + "gsap": "^3.15.0", "lucide-vue-next": "^0.507.0", + "motion-v": "^2.3.0", "pinia": "^2.1.7", "swiper": "^11.0.5", "tailwindcss": "^4.1.4", diff --git a/vite-tailwindcss/pnpm-lock.yaml b/vite-tailwindcss/pnpm-lock.yaml index ade659e..06a991e 100644 --- a/vite-tailwindcss/pnpm-lock.yaml +++ b/vite-tailwindcss/pnpm-lock.yaml @@ -41,9 +41,15 @@ importers: echarts: specifier: ^6.1.0 version: 6.1.0 + gsap: + specifier: ^3.15.0 + version: 3.15.0 lucide-vue-next: specifier: ^0.507.0 version: 0.507.0(vue@3.5.40) + motion-v: + specifier: ^2.3.0 + version: 2.3.0(@vueuse/core@14.4.0(vue@3.5.40))(vue@3.5.40) pinia: specifier: ^2.1.7 version: 2.3.1(vue@3.5.40) @@ -630,6 +636,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@unhead/dom@1.11.20': resolution: {integrity: sha512-jgfGYdOH+xHJF/j8gudjsYu3oIjFyXhCWcgKaw3vQnT616gSqyqnGQGOItL+BQtQZACKNISwIfx5PuOtztMKLA==} @@ -684,11 +693,24 @@ packages: '@vue/shared@3.5.40': resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==} + '@vueuse/core@14.4.0': + resolution: {integrity: sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==} + peerDependencies: + vue: ^3.5.0 + '@vueuse/head@2.0.0': resolution: {integrity: sha512-ykdOxTGs95xjD4WXE4na/umxZea2Itl0GWBILas+O4oqS7eXIods38INvk3XkJKjqMdWPcpCyLX/DioLQxU1KA==} peerDependencies: vue: '>=2.7 || >=3' + '@vueuse/metadata@14.4.0': + resolution: {integrity: sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==} + + '@vueuse/shared@14.4.0': + resolution: {integrity: sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==} + peerDependencies: + vue: ^3.5.0 + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -819,6 +841,20 @@ packages: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} + framer-motion@12.43.0: + resolution: {integrity: sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -842,6 +878,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gsap@3.15.0: + resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -854,6 +893,9 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hey-listen@1.0.8: + resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -988,6 +1030,18 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + motion-dom@12.43.0: + resolution: {integrity: sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + + motion-v@2.3.0: + resolution: {integrity: sha512-J0CCfXtICCni9RjotDUBOs57xNpYI9yyBSohEOxaRHrmjwOtlw291fhRu/mdgEdSasys96R028YDDOAtWBbRaA==} + peerDependencies: + '@vueuse/core': '>=10.0.0' + vue: '>=3.0.0' + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1084,6 +1138,9 @@ packages: tslib@2.3.0: resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + unhead@1.11.20: resolution: {integrity: sha512-3AsNQC0pjwlLqEYHLjtichGWankK8yqmocReITecmpB1H0aOabeESueyy+8X1gyJx4ftZVwo9hqQ4O3fPWffCA==} @@ -1526,6 +1583,8 @@ snapshots: '@types/estree@1.0.9': {} + '@types/web-bluetooth@0.0.21': {} + '@unhead/dom@1.11.20': dependencies: '@unhead/schema': 1.11.20 @@ -1615,6 +1674,13 @@ snapshots: '@vue/shared@3.5.40': {} + '@vueuse/core@14.4.0(vue@3.5.40)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.4.0 + '@vueuse/shared': 14.4.0(vue@3.5.40) + vue: 3.5.40 + '@vueuse/head@2.0.0(vue@3.5.40)': dependencies: '@unhead/dom': 1.11.20 @@ -1623,6 +1689,12 @@ snapshots: '@unhead/vue': 1.11.20(vue@3.5.40) vue: 3.5.40 + '@vueuse/metadata@14.4.0': {} + + '@vueuse/shared@14.4.0(vue@3.5.40)': + dependencies: + vue: 3.5.40 + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -1784,6 +1856,12 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 + framer-motion@12.43.0: + dependencies: + motion-dom: 12.43.0 + motion-utils: 12.39.0 + tslib: 2.8.1 + fsevents@2.3.3: optional: true @@ -1811,6 +1889,8 @@ snapshots: graceful-fs@4.2.11: {} + gsap@3.15.0: {} + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -1821,6 +1901,8 @@ snapshots: dependencies: function-bind: 1.1.2 + hey-listen@1.0.8: {} + hookable@5.5.3: {} https-proxy-agent@5.0.1: @@ -1919,6 +2001,25 @@ snapshots: dependencies: mime-db: 1.52.0 + motion-dom@12.43.0: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + + motion-v@2.3.0(@vueuse/core@14.4.0(vue@3.5.40))(vue@3.5.40): + dependencies: + '@vueuse/core': 14.4.0(vue@3.5.40) + framer-motion: 12.43.0 + hey-listen: 1.0.8 + motion-dom: 12.43.0 + motion-utils: 12.39.0 + vue: 3.5.40 + transitivePeerDependencies: + - '@emotion/is-prop-valid' + - react + - react-dom + ms@2.1.3: {} nanoid@3.3.16: {} @@ -2020,6 +2121,8 @@ snapshots: tslib@2.3.0: {} + tslib@2.8.1: {} + unhead@1.11.20: dependencies: '@unhead/dom': 1.11.20 diff --git a/vite-tailwindcss/src/api/wedding.js b/vite-tailwindcss/src/api/wedding.js index bfe6283..4a76e9a 100644 --- a/vite-tailwindcss/src/api/wedding.js +++ b/vite-tailwindcss/src/api/wedding.js @@ -84,6 +84,16 @@ export function saveUploadConfig(payload) { return service.post('/upload/config', payload) } +/** side 可选:male|female|n|nv;不传则返回 { male, female } 整包 */ +export function getPosterConfig(side) { + const params = side ? { side } : undefined + return service.get('/poster/config', { params }) +} + +export function savePosterConfig(payload) { + return service.post('/poster/config', payload) +} + export function uploadImage(file) { const form = new FormData() form.append('file', file) diff --git a/vite-tailwindcss/src/components/vue-bits/BlurText.vue b/vite-tailwindcss/src/components/vue-bits/BlurText.vue new file mode 100644 index 0000000..21ace2f --- /dev/null +++ b/vite-tailwindcss/src/components/vue-bits/BlurText.vue @@ -0,0 +1,159 @@ + + + diff --git a/vite-tailwindcss/src/components/vue-bits/TextType.vue b/vite-tailwindcss/src/components/vue-bits/TextType.vue new file mode 100644 index 0000000..dbaa8a4 --- /dev/null +++ b/vite-tailwindcss/src/components/vue-bits/TextType.vue @@ -0,0 +1,186 @@ + + + diff --git a/vite-tailwindcss/src/composables/posterDefaults.js b/vite-tailwindcss/src/composables/posterDefaults.js new file mode 100644 index 0000000..7827ab0 --- /dev/null +++ b/vite-tailwindcss/src/composables/posterDefaults.js @@ -0,0 +1,147 @@ +/** 海报 side:male=/hb-n 男方,female=/hb-nv 女方 */ + +export const POSTER_SIDES = [ + { key: 'male', route: '/hb-n', label: '男方', short: 'n' }, + { key: 'female', route: '/hb-nv', label: '女方', short: 'nv' }, +] + +export function resolvePosterSide(input) { + const v = String(input || '').trim().toLowerCase() + if (v === 'female' || v === 'nv' || v === 'f' || v === 'bride') return 'female' + if (v === 'male' || v === 'n' || v === 'm' || v === 'groom') return 'male' + return '' +} + +function baseFields() { + return { + enabled: true, + heroImg: '', + title: '婚礼邀请函', + footer: '—诚挚邀请 ♥ 恭请光临—', + loadingEffect: 'redSeal', + enterEffect: 'fadeUp', + showInviteButton: true, + inviteButtonText: '打开请柬', + invitePath: '/', + } +} + +/** 男方默认文案 */ +export function defaultMalePosterConfig() { + return { + ...baseFields(), + sonLabel: '儿子:', + sonName: '张浩强', + daughterInLawLabel: '儿媳:', + daughterInLawName: '刘佳怡', + lines: [ + '各位亲朋好友', + '吾家有喜 儿子结婚', + '良辰已定 吉日待访', + '兹定于2026年10月3日 星期日', + '农历九月初八 中午11:08分', + '为儿子儿媳举办结婚典礼', + '敬备喜宴 诚邀您携家人光临', + '地址:幸福大酒店二楼宴会厅', + '张国栋 & 陈晓玲 夫 妇 敬邀', + ], + } +} + +/** 女方默认文案 */ +export function defaultFemalePosterConfig() { + return { + ...baseFields(), + sonLabel: '女儿:', + sonName: '刘佳怡', + daughterInLawLabel: '女婿:', + daughterInLawName: '张浩强', + lines: [ + '各位亲朋好友', + '吾家有喜 女儿出嫁', + '良辰已定 吉日待访', + '兹定于2026年10月3日 星期日', + '农历九月初八 中午11:08分', + '为女儿女婿举办结婚典礼', + '敬备喜宴 诚邀您携家人光临', + '地址:幸福大酒店二楼宴会厅', + '刘建国 & 王秀兰 夫 妇 敬邀', + ], + } +} + +/** @deprecated 兼容旧调用,等同男方默认 */ +export function defaultPosterConfig() { + return defaultMalePosterConfig() +} + +export function defaultPosterBundle() { + return { + male: defaultMalePosterConfig(), + female: defaultFemalePosterConfig(), + } +} + +export const LOADING_EFFECTS = [ + { value: 'fade', label: '淡入加载' }, + { value: 'redSeal', label: '红印囍字' }, + { value: 'goldShimmer', label: '金字微光' }, +] + +export const ENTER_EFFECTS = [ + { value: 'fadeUp', label: '上浮淡入' }, + { value: 'scaleIn', label: '缩放进入' }, + { value: 'curtain', label: '金幕拉开' }, +] + +/** 是否为旧版扁平配置(未分男女方) */ +function isLegacyFlatPoster(raw) { + if (!raw || typeof raw !== 'object') return false + if (raw.male || raw.female) return false + return 'title' in raw || 'lines' in raw || 'heroImg' in raw || 'sonName' in raw +} + +export function normalizePosterConfig(raw, side = 'male') { + const s = resolvePosterSide(side) || 'male' + const base = s === 'female' ? defaultFemalePosterConfig() : defaultMalePosterConfig() + const loaded = raw && typeof raw === 'object' ? raw : {} + const lines = Array.isArray(loaded.lines) + ? loaded.lines.map((l) => String(l ?? '')) + : base.lines + return { + ...base, + ...loaded, + lines: lines.length ? lines : base.lines, + enabled: loaded.enabled !== false, + showInviteButton: loaded.showInviteButton !== false, + loadingEffect: LOADING_EFFECTS.some((e) => e.value === loaded.loadingEffect) + ? loaded.loadingEffect + : base.loadingEffect, + enterEffect: ENTER_EFFECTS.some((e) => e.value === loaded.enterEffect) + ? loaded.enterEffect + : base.enterEffect, + invitePath: loaded.invitePath || '/', + inviteButtonText: loaded.inviteButtonText || base.inviteButtonText, + } +} + +/** 归一化整包 { male, female };兼容旧扁平数据迁入 male */ +export function normalizePosterBundle(raw) { + const loaded = raw && typeof raw === 'object' ? raw : {} + if (isLegacyFlatPoster(loaded)) { + return { + male: normalizePosterConfig(loaded, 'male'), + female: defaultFemalePosterConfig(), + } + } + return { + male: normalizePosterConfig(loaded.male, 'male'), + female: normalizePosterConfig(loaded.female, 'female'), + } +} + +export function pickPosterSide(bundle, side) { + const normalized = normalizePosterBundle(bundle) + const s = resolvePosterSide(side) || 'male' + return normalized[s] +} diff --git a/vite-tailwindcss/src/router/index.js b/vite-tailwindcss/src/router/index.js index 88a4f9a..49a93d4 100644 --- a/vite-tailwindcss/src/router/index.js +++ b/vite-tailwindcss/src/router/index.js @@ -8,6 +8,22 @@ const routes = [ name: 'Home', component: () => import('@/views/index/index.vue'), }, + { + path: '/hb', + redirect: '/hb-n', + }, + { + path: '/hb-n', + name: 'PosterMale', + component: () => import('@/views/hb/index.vue'), + meta: { posterSide: 'male' }, + }, + { + path: '/hb-nv', + name: 'PosterFemale', + component: () => import('@/views/hb/index.vue'), + meta: { posterSide: 'female' }, + }, { path: '/login', name: 'Login', diff --git a/vite-tailwindcss/src/views/admin/AdminEditor.vue b/vite-tailwindcss/src/views/admin/AdminEditor.vue index 30de52c..486e745 100644 --- a/vite-tailwindcss/src/views/admin/AdminEditor.vue +++ b/vite-tailwindcss/src/views/admin/AdminEditor.vue @@ -13,6 +13,17 @@ 预览请柬 + + + 预览海报 + + + + + + + + + + + + +
+ + + + + + + + + +
+
+ + + {{ opt.label }} + + + + + {{ opt.label }} + + + + + +
+ + + +
+ +
+ + +
+
+ +
+ + +
+
+
+ + + + + + + + + +
+ + 保存海报配置 + + + 打开 {{ posterSideTab === 'female' ? '/hb-nv' : '/hb-n' }} 预览 + +
+
+
+
+
@@ -731,12 +821,17 @@ import { CanvasRenderer } from 'echarts/renderers' import { getConfigSection, saveConfigSection, uploadImage, getRsvpList, getUploadConfig, saveUploadConfig, getDanmakuList, updateDanmakuStatus, getVisitStats, getVisitList, + getPosterConfig, savePosterConfig, } from '@/api/wedding' import { useAdminStore } from '@/stores/admin' import ImageField from '@/components/ImageField.vue' import { BORDER_STYLES, borderClass } from '@/composables/borderStyles' import { resolveDanmakuSwatch } from '@/utils/danmakuColors' import { getSlotResizeSpecs, resizeSlotToFile, probeResizedMeta, metaHasFileSize } from '@/composables/resizeImage' +import { + defaultPosterBundle, normalizePosterBundle, normalizePosterConfig, + LOADING_EFFECTS, ENTER_EFFECTS, POSTER_SIDES, +} from '@/composables/posterDefaults' echarts.use([PieChart, TooltipComponent, LegendComponent, CanvasRenderer]) @@ -745,7 +840,7 @@ const adminStore = useAdminStore() const TAB_KEY = 'wedding_admin_active_tab' const CONFIG_SECTIONS = ['basic', 'copy', 'visual', 'music', 'photos', 'schedule'] -const VALID_TABS = ['overview', ...CONFIG_SECTIONS, 'upload', 'rsvp', 'danmaku'] +const VALID_TABS = ['overview', ...CONFIG_SECTIONS, 'upload', 'poster', 'rsvp', 'danmaku'] const SECTION_KEYS = { basic: ['groom', 'bride', 'date', 'lunar', 'calendarDate', 'hotel', 'address', 'heroImg', 'endImg'], @@ -774,6 +869,8 @@ const uploading = ref(false) const resizingPage = ref(false) const resizeProgress = ref('') const uploadConfigSaving = ref(false) +const posterSaving = ref(false) +const posterLoading = ref(false) const tabLoading = ref(false) const rsvpLoading = ref(false) const rsvpList = ref([]) @@ -791,6 +888,22 @@ let regionChart = null const cfg = reactive(defaultConfig()) const uploadCfg = reactive(defaultUploadConfig()) +const posterSideTab = ref('male') +const posterBundle = reactive(defaultPosterBundle()) +const posterCfg = computed(() => posterBundle[posterSideTab.value] || posterBundle.male) +const posterLinesText = computed({ + get: () => { + const lines = posterCfg.value?.lines + return Array.isArray(lines) ? lines.join('\n') : '' + }, + set: (v) => { + const side = posterSideTab.value + if (!posterBundle[side]) posterBundle[side] = normalizePosterConfig(null, side) + posterBundle[side].lines = String(v || '') + .split('\n') + .map((s) => s.trimEnd()) + }, +}) const currentPhotoIndex = computed(() => Math.min(Number(activePhotoTab.value) || 0, Math.max(cfg.photoPages.length - 1, 0))) const isConfigTab = computed(() => CONFIG_SECTIONS.includes(activeTab.value)) const visitPagination = computed(() => ({ @@ -1206,6 +1319,36 @@ async function onSaveUploadConfig() { } } +async function loadPosterConfig() { + posterLoading.value = true + try { + const res = await getPosterConfig() + const bundle = normalizePosterBundle(res.data || {}) + posterBundle.male = bundle.male + posterBundle.female = bundle.female + } catch (e) { + message.error(e?.message || '海报配置加载失败') + } finally { + posterLoading.value = false + } +} + +async function onSavePosterConfig() { + posterSaving.value = true + try { + const payload = normalizePosterBundle(posterBundle) + const res = await savePosterConfig(payload) + const bundle = normalizePosterBundle(res.data || payload) + posterBundle.male = bundle.male + posterBundle.female = bundle.female + message.success('海报配置已保存') + } catch (e) { + message.error(e?.message || '海报配置保存失败') + } finally { + posterSaving.value = false + } +} + function pickSectionData(section) { const keys = SECTION_KEYS[section] || [] const data = {} @@ -1390,6 +1533,10 @@ async function onTabActivate(tab) { await loadUploadConfig() return } + if (tab === 'poster') { + await loadPosterConfig() + return + } if (tab === 'rsvp') { await loadRsvp() return @@ -1400,6 +1547,10 @@ async function onTabActivate(tab) { } function openPreview() { window.open('/', '_blank') } +function openPosterPreview(side = 'male') { + const meta = POSTER_SIDES.find((s) => s.key === side) || POSTER_SIDES[0] + window.open(meta.route, '_blank') +} function onLogout() { adminStore.logout() diff --git a/vite-tailwindcss/src/views/hb/index.vue b/vite-tailwindcss/src/views/hb/index.vue new file mode 100644 index 0000000..3ece147 --- /dev/null +++ b/vite-tailwindcss/src/views/hb/index.vue @@ -0,0 +1,862 @@ + + + + + diff --git a/vite-tailwindcss/vite.config.js b/vite-tailwindcss/vite.config.js index b54e842..c342a39 100644 --- a/vite-tailwindcss/vite.config.js +++ b/vite-tailwindcss/vite.config.js @@ -17,10 +17,10 @@ export default defineConfig({ server: { port: 8888, proxy: { - // 婚礼纪 Go 后端运行在 :8080,接口统一以 /api 开头,无需重写 + // 婚礼纪 Go 后端运行在 :15201,接口统一以 /api 开头,无需重写 '/api': { - // target: 'http://127.0.0.1:15201', - target: 'https://hl.yqh.nailaoyun.cn', + target: 'http://127.0.0.1:15201', + // target: 'https://hl.yqh.nailaoyun.cn', changeOrigin: true } }