341 lines
16 KiB
Go
341 lines
16 KiB
Go
package handlers
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"go.mongodb.org/mongo-driver/v2/bson"
|
||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||
|
||
"yh_web/server/config"
|
||
"yh_web/server/models"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
const homepageSlug = "index"
|
||
const officialSiteConfigID = "official_site_id"
|
||
|
||
type officialSiteDoc struct {
|
||
ID string `bson:"_id"`
|
||
SiteID string `bson:"site_id"`
|
||
}
|
||
|
||
// getOfficialSiteID 从 system_config 读取官网站点 ID;未设置则返回第一个站点的 ID
|
||
func getOfficialSiteID(ctx context.Context) string {
|
||
coll := config.GetDB(config.DBName).Collection("system_config")
|
||
var doc officialSiteDoc
|
||
err := coll.FindOne(ctx, bson.M{"_id": officialSiteConfigID}).Decode(&doc)
|
||
if err == nil && doc.SiteID != "" {
|
||
return doc.SiteID
|
||
}
|
||
sitesColl := config.GetDB(config.DBName).Collection("sites")
|
||
opts := options.FindOne().SetSort(bson.D{{Key: "created_at", Value: -1}})
|
||
var site models.Site
|
||
if err := sitesColl.FindOne(ctx, bson.M{}, opts).Decode(&site); err == nil {
|
||
return site.ID.Hex()
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// GetWebHomepage 前台:获取官网站点首页数据(无需鉴权)
|
||
func GetWebHomepage(c *gin.Context) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
|
||
siteID := getOfficialSiteID(ctx)
|
||
if siteID == "" {
|
||
c.JSON(http.StatusOK, defaultHomepageData())
|
||
return
|
||
}
|
||
|
||
coll := config.GetDB(config.DBName).Collection("pages")
|
||
var page models.Page
|
||
err := coll.FindOne(ctx, bson.M{"site_id": siteID, "slug": homepageSlug}).Decode(&page)
|
||
if err != nil {
|
||
if err == mongo.ErrNoDocuments {
|
||
c.JSON(http.StatusOK, defaultHomepageData())
|
||
return
|
||
}
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
var data models.HomepageData
|
||
if page.Content != "" {
|
||
if err := json.Unmarshal([]byte(page.Content), &data); err != nil {
|
||
c.JSON(http.StatusOK, defaultHomepageData())
|
||
return
|
||
}
|
||
} else {
|
||
data = defaultHomepageData()
|
||
}
|
||
c.JSON(http.StatusOK, data)
|
||
}
|
||
|
||
// GetHomepage 获取站点首页数据
|
||
func GetHomepage(c *gin.Context) {
|
||
siteID := c.Param("site_id")
|
||
if siteID == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
|
||
return
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
|
||
coll := config.GetDB(config.DBName).Collection("pages")
|
||
var page models.Page
|
||
err := coll.FindOne(ctx, bson.M{"site_id": siteID, "slug": homepageSlug}).Decode(&page)
|
||
if err != nil {
|
||
if err == mongo.ErrNoDocuments {
|
||
c.JSON(http.StatusOK, defaultHomepageData())
|
||
return
|
||
}
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
var data models.HomepageData
|
||
if page.Content != "" {
|
||
if err := json.Unmarshal([]byte(page.Content), &data); err != nil {
|
||
c.JSON(http.StatusOK, defaultHomepageData())
|
||
return
|
||
}
|
||
} else {
|
||
data = defaultHomepageData()
|
||
}
|
||
c.JSON(http.StatusOK, data)
|
||
}
|
||
|
||
// UpdateHomepage 更新站点首页数据
|
||
func UpdateHomepage(c *gin.Context) {
|
||
siteID := c.Param("site_id")
|
||
if siteID == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
|
||
return
|
||
}
|
||
|
||
var data models.HomepageData
|
||
if err := c.ShouldBindJSON(&data); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
body, err := json.Marshal(data)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
|
||
coll := config.GetDB(config.DBName).Collection("pages")
|
||
now := time.Now().Format(time.RFC3339)
|
||
filter := bson.M{"site_id": siteID, "slug": homepageSlug}
|
||
update := bson.M{
|
||
"$set": bson.M{
|
||
"site_id": siteID,
|
||
"slug": homepageSlug,
|
||
"title": data.Title,
|
||
"type": "homepage",
|
||
"content": string(body),
|
||
"updated_at": now,
|
||
},
|
||
}
|
||
opts := options.UpdateOne().SetUpsert(true)
|
||
_, err = coll.UpdateOne(ctx, filter, update, opts)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"message": "保存成功"})
|
||
}
|
||
|
||
// DownloadHomepage 下载首页 HTML
|
||
func DownloadHomepage(c *gin.Context) {
|
||
siteID := c.Param("site_id")
|
||
if siteID == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
|
||
return
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
|
||
coll := config.GetDB(config.DBName).Collection("pages")
|
||
var page models.Page
|
||
var data models.HomepageData
|
||
err := coll.FindOne(ctx, bson.M{"site_id": siteID, "slug": homepageSlug}).Decode(&page)
|
||
if err == nil && page.Content != "" {
|
||
_ = json.Unmarshal([]byte(page.Content), &data)
|
||
}
|
||
if err != nil || page.Content == "" {
|
||
data = defaultHomepageData()
|
||
}
|
||
|
||
html := renderHomepageHTML(&data)
|
||
c.Header("Content-Disposition", "attachment; filename=index.html")
|
||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(html))
|
||
}
|
||
|
||
func defaultHomepageData() models.HomepageData {
|
||
return models.HomepageData{
|
||
LogoText: "宇恒一号",
|
||
NavLinks: []models.NavLink{{Label: "产品简介", URL: "#intro"}, {Label: "产品视频", URL: "#videos"}, {Label: "联系我们", URL: "#contact"}},
|
||
Title: "宇恒一号",
|
||
Subtitle: "",
|
||
Description: "",
|
||
DownloadText: "下载",
|
||
DownloadURL: "#",
|
||
Platforms: []models.PlatformItem{},
|
||
Version: "",
|
||
LaunchYear: "发布日期:以官网为准",
|
||
BadgeText: "完全免费",
|
||
DownloadWindowsURL: "/promotion/downloads/yuheng-windows.zip",
|
||
DownloadAndroidURL: "/promotion/downloads/yuheng-android.apk",
|
||
Features: []models.FeatureItem{
|
||
{Title: "星际导航", Desc: "先进的 AI 导航系统,精准定位您的需求,引领探索之旅"},
|
||
{Title: "量子同步", Desc: "跨维度数据同步技术,您的数据在多宇宙中保持一致"},
|
||
{Title: "星际防护", Desc: "来自未来的安全加密协议,守护您的数字资产安全"},
|
||
},
|
||
FooterText: "© 2024 宇恒一号 · 成都宇信达智能科技有限公司",
|
||
}
|
||
}
|
||
|
||
// renderHomepageHTML 根据数据生成首页 HTML(简化版,保留原样式与结构)
|
||
func renderHomepageHTML(d *models.HomepageData) string {
|
||
if d == nil {
|
||
d = &models.HomepageData{}
|
||
}
|
||
titleChars := splitTitle(d.Title)
|
||
navHTML := ""
|
||
for _, l := range d.NavLinks {
|
||
navHTML += `<a href="` + escape(l.URL) + `" style="color: rgba(255,255,255,0.5); text-decoration: none; transition: color 0.3s;">` + escape(l.Label) + `</a>`
|
||
}
|
||
platformsHTML := ""
|
||
for _, p := range d.Platforms {
|
||
platformsHTML += `<div class="orbit-platform"><svg viewBox="0 0 24 24"><path d="M20 18c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H0v2h24v-2h-4zM4 6h16v10H4V6z"/></svg><span>` + escape(p.Name) + `</span></div>`
|
||
}
|
||
featuresHTML := ""
|
||
for i, f := range d.Features {
|
||
iconPath := []string{
|
||
"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z",
|
||
"M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM19 18H6c-2.21 0-4-1.79-4-4s1.79-4 4-4h.71C7.37 7.69 9.48 6 12 6c3.04 0 5.5 2.46 5.5 5.5v.5H19c1.66 0 3 1.34 3 3s-1.34 3-3 3z",
|
||
"M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z",
|
||
}
|
||
if i >= len(iconPath) {
|
||
iconPath = append(iconPath, iconPath[0])
|
||
}
|
||
path := iconPath[i%3]
|
||
featuresHTML += `<div class="feature-space"><div class="feature-icon"><svg viewBox="0 0 24 24"><path d="` + path + `"/></svg></div><h3>` + escape(f.Title) + `</h3><p>` + escape(f.Desc) + `</p></div>`
|
||
}
|
||
|
||
sb := &strings.Builder{}
|
||
sb.WriteString("<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>")
|
||
sb.WriteString(escape(d.Title))
|
||
sb.WriteString(" - 星际探索版</title>\n")
|
||
sb.WriteString(homepageCSS)
|
||
sb.WriteString("</head>\n<body>\n<div class=\"space-bg\"></div>\n<div class=\"stars\" id=\"stars\"></div>\n<div class=\"planet planet-1\"></div>\n<div class=\"planet planet-2\"></div>\n<nav class=\"navbar\"><div class=\"logo-space\">")
|
||
sb.WriteString(escape(d.LogoText))
|
||
sb.WriteString("</div>\n<div style=\"display: flex; gap: 35px; font-family: 'Exo 2', sans-serif; font-size: 12px; letter-spacing: 2px;\">")
|
||
sb.WriteString(navHTML)
|
||
sb.WriteString("</div>\n</nav>\n<section class=\"hero\">\n<div class=\"title-container\"><h1 class=\"title-3d\">")
|
||
for _, ch := range titleChars {
|
||
sb.WriteString("<span>" + escape(ch) + "</span>")
|
||
}
|
||
sb.WriteString("</h1>\n</div>\n<p class=\"subtitle-space\">")
|
||
sb.WriteString(escape(d.Subtitle))
|
||
sb.WriteString("</p>\n<p class=\"description-space\">")
|
||
sb.WriteString(strings.ReplaceAll(escape(d.Description), "\n", "<br>\n"))
|
||
sb.WriteString("</p>\n<div class=\"download-warp\">\n<div class=\"warp-effect\"></div>\n<a href=\"")
|
||
sb.WriteString(escape(d.DownloadURL))
|
||
sb.WriteString("\" class=\"warp-btn\">\n<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z\"/></svg>\n")
|
||
sb.WriteString(escape(d.DownloadText))
|
||
sb.WriteString("\n</a>\n</div>\n<div class=\"orbit-platforms\">")
|
||
sb.WriteString(platformsHTML)
|
||
sb.WriteString("</div>\n<div class=\"mission-info\">\n<span class=\"mission-badge\">")
|
||
sb.WriteString(escape(d.Version))
|
||
sb.WriteString("</span>\n<span>🚀 ")
|
||
sb.WriteString(escape(d.LaunchYear))
|
||
sb.WriteString("</span>\n<span>⚡ ")
|
||
sb.WriteString(escape(d.BadgeText))
|
||
sb.WriteString("</span>\n</div>\n<div class=\"features-space\">")
|
||
sb.WriteString(featuresHTML)
|
||
sb.WriteString("</div>\n</section>\n<footer>\n<p>")
|
||
sb.WriteString(escape(d.FooterText))
|
||
sb.WriteString("</p>\n</footer>\n<script>\nconst starsContainer = document.getElementById('stars');\nfor (let i = 0; i < 200; i++) {\nconst star = document.createElement('div');\nstar.className = 'star';\nstar.style.left = Math.random() * 100 + '%';\nstar.style.top = Math.random() * 100 + '%';\nstar.style.width = (Math.random() * 2 + 1) + 'px';\nstar.style.height = star.style.width;\nstar.style.setProperty('--duration', (Math.random() * 3 + 2) + 's');\nstar.style.setProperty('--min-opacity', Math.random() * 0.3 + 0.1);\nstarsContainer.appendChild(star);\n}\n</script>\n</body>\n</html>")
|
||
return sb.String()
|
||
}
|
||
|
||
func splitTitle(s string) []string {
|
||
var out []rune
|
||
for _, r := range s {
|
||
out = append(out, r)
|
||
}
|
||
if len(out) == 0 {
|
||
return []string{"宇", "恒", "一", "号"}
|
||
}
|
||
result := make([]string, len(out))
|
||
for i, r := range out {
|
||
result[i] = string(r)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func escape(s string) string {
|
||
s = strings.ReplaceAll(s, "&", "&")
|
||
s = strings.ReplaceAll(s, "<", "<")
|
||
s = strings.ReplaceAll(s, ">", ">")
|
||
s = strings.ReplaceAll(s, "\"", """)
|
||
return s
|
||
}
|
||
|
||
const homepageCSS = `<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=Exo+2:wght@300;400;700;900&family=Noto+Sans+SC:wght@300;400;700&display=swap');
|
||
*{margin:0;padding:0;box-sizing:border-box;}
|
||
:root{--space-dark:#0a0a12;--space-blue:#1e3a5f;--nebula-purple:#4a1a6b;--star-white:#fff;--plasma-cyan:#00d4ff;--plasma-pink:#ff2d95;}
|
||
body{font-family:'Noto Sans SC',sans-serif;background:var(--space-dark);color:var(--star-white);min-height:100vh;overflow-x:hidden;}
|
||
.space-bg{position:fixed;top:0;left:0;width:100%;height:100%;z-index:0;background:radial-gradient(ellipse at 20% 80%,rgba(74,26,107,0.3) 0%,transparent 50%),radial-gradient(ellipse at 80% 20%,rgba(30,58,95,0.3) 0%,transparent 50%);}
|
||
.stars{position:fixed;top:0;left:0;width:100%;height:100%;z-index:1;pointer-events:none;}
|
||
.star{position:absolute;background:#fff;border-radius:50%;animation:twinkle 3s ease-in-out infinite;}
|
||
@keyframes twinkle{0%,100%{opacity:0.3;transform:scale(1);}50%{opacity:1;transform:scale(1.2);}}
|
||
.planet{position:fixed;border-radius:50%;z-index:2;pointer-events:none;}
|
||
.planet-1{width:300px;height:300px;top:10%;right:-100px;background:linear-gradient(135deg,var(--nebula-purple),#1a0a2e);opacity:0.6;}
|
||
.planet-2{width:150px;height:150px;bottom:20%;left:-50px;background:linear-gradient(135deg,var(--space-blue),#0a1520);opacity:0.5;}
|
||
.navbar{position:fixed;top:0;left:0;right:0;padding:25px 50px;display:flex;justify-content:space-between;align-items:center;z-index:100;background:linear-gradient(180deg,rgba(10,10,18,0.9) 0%,transparent 100%);}
|
||
.logo-space{font-family:'Exo 2',sans-serif;font-size:26px;font-weight:900;background:linear-gradient(90deg,var(--plasma-cyan),var(--star-white),var(--plasma-pink));-webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:4px;}
|
||
.hero{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:100px 20px;position:relative;z-index:10;}
|
||
.title-container{perspective:1000px;margin-bottom:30px;}
|
||
.title-3d{font-family:'Exo 2',sans-serif;font-size:clamp(60px,12vw,150px);font-weight:900;color:var(--star-white);text-shadow:0 0 20px rgba(0,212,255,0.3);}
|
||
.subtitle-space{font-family:'Exo 2',sans-serif;font-size:clamp(16px,3vw,24px);letter-spacing:12px;color:var(--plasma-cyan);margin-bottom:25px;}
|
||
.description-space{max-width:650px;text-align:center;color:rgba(255,255,255,0.6);line-height:2;font-size:16px;margin-bottom:50px;}
|
||
.download-warp{position:relative;display:inline-block;padding:0;background:transparent;border:none;cursor:pointer;}
|
||
.download-warp .warp-btn{display:flex;align-items:center;gap:15px;padding:22px 45px;font-size:16px;font-weight:700;color:var(--space-dark);background:linear-gradient(135deg,var(--plasma-cyan),var(--plasma-pink));border:none;font-family:'Exo 2',sans-serif;letter-spacing:2px;text-decoration:none;clip-path:polygon(20px 0,100% 0,100% calc(100% - 20px),calc(100% - 20px) 100%,0 100%,0 20px);transition:all 0.4s;}
|
||
.download-warp .warp-btn:hover{transform:scale(1.05);box-shadow:0 0 30px rgba(0,212,255,0.6);}
|
||
.warp-effect{position:absolute;top:50%;left:50%;width:0;height:0;background:radial-gradient(circle,rgba(255,255,255,0.8) 0%,transparent 70%);border-radius:50%;transform:translate(-50%,-50%);animation:warp-drive 1.5s ease-out infinite;}
|
||
@keyframes warp-drive{0%{width:0;height:0;opacity:1;}100%{width:300px;height:300px;opacity:0;}}
|
||
.orbit-platforms{display:flex;gap:25px;margin-top:70px;flex-wrap:wrap;justify-content:center;}
|
||
.orbit-platform{width:90px;height:90px;background:rgba(30,58,95,0.3);border:1px solid rgba(0,212,255,0.3);border-radius:50%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;cursor:pointer;transition:all 0.4s;}
|
||
.orbit-platform:hover{background:rgba(0,212,255,0.2);border-color:var(--plasma-cyan);transform:translateY(-10px);}
|
||
.orbit-platform svg{width:28px;height:28px;fill:var(--star-white);}
|
||
.orbit-platform span{font-size:10px;color:rgba(255,255,255,0.6);}
|
||
.mission-info{margin-top:60px;display:flex;gap:40px;flex-wrap:wrap;justify-content:center;font-size:13px;color:rgba(255,255,255,0.4);}
|
||
.mission-info .mission-badge{padding:4px 12px;background:rgba(0,212,255,0.1);border:1px solid rgba(0,212,255,0.3);border-radius:20px;color:var(--plasma-cyan);font-size:11px;}
|
||
.features-space{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:30px;max-width:1100px;margin:80px auto 0;padding:0 20px;}
|
||
.feature-space{background:linear-gradient(135deg,rgba(30,58,95,0.2),rgba(10,10,18,0.8));border:1px solid rgba(0,212,255,0.1);border-radius:20px;padding:35px;transition:all 0.5s;}
|
||
.feature-space:hover{transform:translateY(-15px);border-color:rgba(0,212,255,0.4);}
|
||
.feature-icon{width:55px;height:55px;background:linear-gradient(135deg,var(--plasma-cyan),var(--plasma-pink));border-radius:15px;display:flex;align-items:center;justify-content:center;margin-bottom:20px;}
|
||
.feature-icon svg{width:28px;height:28px;fill:var(--space-dark);}
|
||
.feature-space h3{font-family:'Exo 2',sans-serif;font-size:18px;color:var(--star-white);margin-bottom:12px;}
|
||
.feature-space p{color:rgba(255,255,255,0.5);font-size:14px;line-height:1.7;}
|
||
footer{padding:40px;text-align:center;border-top:1px solid rgba(255,255,255,0.05);margin-top:80px;}
|
||
footer p{color:rgba(255,255,255,0.3);font-size:12px;}
|
||
@media(max-width:768px){.planet{display:none;}}
|
||
</style>
|
||
` |