代码推送
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# ========== 服务配置 ==========
|
||||
server:
|
||||
port: 8080
|
||||
port: 6655
|
||||
read_timeout: 15 # 读超时(秒)
|
||||
write_timeout: 30 # 写超时(秒)
|
||||
idle_timeout: 120 # 空闲超时(秒)
|
||||
@@ -107,6 +107,12 @@ ai:
|
||||
api_type: "deepseek" # deepseek | openai
|
||||
model: "deepseek-chat"
|
||||
|
||||
# ========== 企业微信 Webhook ==========
|
||||
wechat_webhook:
|
||||
enabled: true
|
||||
url: "" # 企业微信群机器人 Webhook URL
|
||||
# 示例: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
|
||||
# ========== CORS 跨域 ==========
|
||||
cors:
|
||||
allowed_origins:
|
||||
|
||||
@@ -9,26 +9,27 @@ import (
|
||||
|
||||
// Config 全局配置根结构
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
JWT JWTConfig `yaml:"jwt"`
|
||||
Upload UploadConfig `yaml:"upload"`
|
||||
Storage StorageConfig `yaml:"storage"`
|
||||
SMS SMSConfig `yaml:"sms"`
|
||||
Wechat WechatConfig `yaml:"wechat"`
|
||||
AI AIConfig `yaml:"ai"`
|
||||
CORS CORSConfig `yaml:"cors"`
|
||||
RateLimit RateLimitConfig `yaml:"rate_limit"`
|
||||
Env EnvConfig `yaml:"env"`
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
JWT JWTConfig `yaml:"jwt"`
|
||||
Upload UploadConfig `yaml:"upload"`
|
||||
Storage StorageConfig `yaml:"storage"`
|
||||
SMS SMSConfig `yaml:"sms"`
|
||||
Wechat WechatConfig `yaml:"wechat"`
|
||||
WechatWebhook WechatWebhookConfig `yaml:"wechat_webhook"`
|
||||
AI AIConfig `yaml:"ai"`
|
||||
CORS CORSConfig `yaml:"cors"`
|
||||
RateLimit RateLimitConfig `yaml:"rate_limit"`
|
||||
Env EnvConfig `yaml:"env"`
|
||||
}
|
||||
|
||||
// ServerConfig 服务配置
|
||||
type ServerConfig struct {
|
||||
Port int `yaml:"port"`
|
||||
ReadTimeout int `yaml:"read_timeout"` // 读超时(秒)
|
||||
WriteTimeout int `yaml:"write_timeout"` // 写超时(秒)
|
||||
IdleTimeout int `yaml:"idle_timeout"` // 空闲超时(秒)
|
||||
ShutdownTimeout int `yaml:"shutdown_timeout"` // 优雅关机等待(秒)
|
||||
Port int `yaml:"port"`
|
||||
ReadTimeout int `yaml:"read_timeout"` // 读超时(秒)
|
||||
WriteTimeout int `yaml:"write_timeout"` // 写超时(秒)
|
||||
IdleTimeout int `yaml:"idle_timeout"` // 空闲超时(秒)
|
||||
ShutdownTimeout int `yaml:"shutdown_timeout"` // 优雅关机等待(秒)
|
||||
}
|
||||
|
||||
// DatabaseConfig 数据库配置
|
||||
@@ -152,6 +153,12 @@ type WechatConfig struct {
|
||||
NotifyURL string `yaml:"notify_url"`
|
||||
}
|
||||
|
||||
// WechatWebhookConfig 企业微信群机器人Webhook配置
|
||||
type WechatWebhookConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
URL string `yaml:"url"`
|
||||
}
|
||||
|
||||
// AIConfig AI 配置
|
||||
type AIConfig struct {
|
||||
APIKey string `yaml:"api_key"`
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"gotest/internal/model"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -61,7 +60,7 @@ func Init(cfg *config.DatabaseConfig) (*DB, error) {
|
||||
// auto_migrate 自动建表/更新表结构
|
||||
if cfg.AutoMigrate {
|
||||
if err := db.AutoMigrate(
|
||||
&model.User{}, &model.Config{},
|
||||
&model.User{}, &model.Config{}, &model.AccessLog{},
|
||||
&model.Company{}, &model.EnterpriseNews{}, &model.EnterpriseService{}, &model.TeamMember{},
|
||||
&model.ProductCategory{}, &model.Product{},
|
||||
&model.RestaurantCategory{}, &model.Restaurant{}, &model.MenuCategory{}, &model.MenuItem{},
|
||||
@@ -102,9 +101,9 @@ func Init(cfg *config.DatabaseConfig) (*DB, error) {
|
||||
return &DB{db}, nil
|
||||
}
|
||||
|
||||
// seedData 初始化基础数据,仅在用户表为空时执行
|
||||
// seedData 初始化基础数据
|
||||
func seedData(db *gorm.DB) error {
|
||||
// 检查是否已有用户,避免重复初始化
|
||||
// 检查是否已有用户
|
||||
var count int64
|
||||
if err := db.Model(&model.User{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
@@ -114,24 +113,7 @@ func seedData(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 创建默认管理员账号 admin / admin123
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("生成管理员密码哈希失败: %w", err)
|
||||
}
|
||||
|
||||
admin := model.User{
|
||||
Username: "admin",
|
||||
Password: string(hashedPassword),
|
||||
Role: "admin",
|
||||
Status: 1,
|
||||
}
|
||||
|
||||
if err := db.Create(&admin).Error; err != nil {
|
||||
return fmt.Errorf("创建默认管理员失败: %w", err)
|
||||
}
|
||||
log.Println("默认管理员账号已创建: admin / admin123(请及时修改密码)")
|
||||
|
||||
log.Println("未检测到管理员账号,请通过后台初始化页面创建管理员")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ func seedEnterpriseData(db *gorm.DB) error {
|
||||
Slogan: "创新驱动 · 智领未来",
|
||||
Description: "Nexus 科技集团成立于 2010 年,是一家专注于人工智能、云计算和物联网技术的高科技企业。我们致力于为全球客户提供数字化转型解决方案,业务覆盖智能制造、智慧城市、金融科技等多个领域。经过十余年发展,集团已拥有超过 2000 名员工,服务客户超过 5000 家,足迹遍及亚洲、欧洲和北美。",
|
||||
Vision: "成为全球领先的智能科技企业,用技术赋能每一个行业。",
|
||||
Mission: "以创新技术为核心驱动力,为客户创造价值,为社会承担责任,为员工提供平台。",
|
||||
Mission: "以创新技术为核心驱动力,为客户创造价值,为社会承担责任,为员工提供平台。",
|
||||
Phone: "400-888-9999",
|
||||
Email: "contact@nexus-tech.com",
|
||||
Address: "上海市浦东新区张江高科技园区博云路 2 号",
|
||||
|
||||
@@ -59,7 +59,10 @@ func seedFoodData(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
// 为每个餐厅创建菜品分类和菜品
|
||||
type catSeed struct{ name string; items []model.MenuItem }
|
||||
type catSeed struct {
|
||||
name string
|
||||
items []model.MenuItem
|
||||
}
|
||||
restaurantMenus := map[uint][]catSeed{
|
||||
// 蜀香苑 川菜
|
||||
1: {
|
||||
|
||||
107
internal/geoip/geoip.go
Normal file
107
internal/geoip/geoip.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ipLocation 缓存的 IP 归属地信息
|
||||
type ipLocation struct {
|
||||
Location string
|
||||
FetchedAt time.Time
|
||||
}
|
||||
|
||||
// Service IP 归属地查询服务(基于 ip-api.com 免费接口 + 内存缓存)
|
||||
type Service struct {
|
||||
cache sync.Map
|
||||
}
|
||||
|
||||
// NewService 创建 IP 归属地查询服务
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
// ipApiResponse ip-api.com 返回结构
|
||||
type ipApiResponse struct {
|
||||
Status string `json:"status"`
|
||||
Country string `json:"country"`
|
||||
RegionName string `json:"regionName"`
|
||||
City string `json:"city"`
|
||||
ISP string `json:"isp"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
// Lookup 查询 IP 归属地,带内存缓存(24小时有效)
|
||||
func (s *Service) Lookup(ip string) string {
|
||||
// 本地/内网 IP 直接返回
|
||||
if ip == "127.0.0.1" || ip == "::1" || ip == "" ||
|
||||
strings.HasPrefix(ip, "192.168.") ||
|
||||
strings.HasPrefix(ip, "10.") ||
|
||||
strings.HasPrefix(ip, "172.") {
|
||||
return "本地网络"
|
||||
}
|
||||
|
||||
// 检查缓存(24小时有效)
|
||||
if v, ok := s.cache.Load(ip); ok {
|
||||
loc := v.(*ipLocation)
|
||||
if time.Since(loc.FetchedAt) < 24*time.Hour {
|
||||
return loc.Location
|
||||
}
|
||||
s.cache.Delete(ip)
|
||||
}
|
||||
|
||||
// 查询 ip-api.com(免费接口,限制 45 次/分钟)
|
||||
location := s.queryIPAPI(ip)
|
||||
if location == "" {
|
||||
return "未知"
|
||||
}
|
||||
|
||||
// 写入缓存
|
||||
s.cache.Store(ip, &ipLocation{
|
||||
Location: location,
|
||||
FetchedAt: time.Now(),
|
||||
})
|
||||
|
||||
return location
|
||||
}
|
||||
|
||||
// queryIPAPI 调用 ip-api.com 查询
|
||||
func (s *Service) queryIPAPI(ip string) string {
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
url := fmt.Sprintf("http://ip-api.com/json/%s?lang=zh-CN", ip)
|
||||
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result ipApiResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if result.Status != "success" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := []string{}
|
||||
if result.Country != "" {
|
||||
parts = append(parts, result.Country)
|
||||
}
|
||||
if result.RegionName != "" {
|
||||
parts = append(parts, result.RegionName)
|
||||
}
|
||||
if result.City != "" {
|
||||
parts = append(parts, result.City)
|
||||
}
|
||||
if result.ISP != "" {
|
||||
parts = append(parts, result.ISP)
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
107
internal/handler/access_log.go
Normal file
107
internal/handler/access_log.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"gotest/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AccessLogHandler 访问日志处理器
|
||||
type AccessLogHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAccessLogHandler 创建访问日志处理器
|
||||
func NewAccessLogHandler(db *gorm.DB) *AccessLogHandler {
|
||||
return &AccessLogHandler{db: db}
|
||||
}
|
||||
|
||||
// ListLogs 获取访问日志列表 GET /api/v1/admin/access-logs
|
||||
func (h *AccessLogHandler) ListLogs(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
ip := c.Query("ip")
|
||||
path := c.Query("path")
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
query := h.db.Model(&model.AccessLog{})
|
||||
|
||||
if ip != "" {
|
||||
query = query.Where("ip LIKE ?", "%"+ip+"%")
|
||||
}
|
||||
if path != "" {
|
||||
query = query.Where("path LIKE ?", "%"+path+"%")
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var logs []model.AccessLog
|
||||
if err := query.Order("id DESC").Offset(offset).Limit(pageSize).Find(&logs).Error; err != nil {
|
||||
FailInternal(c, "查询失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, gin.H{
|
||||
"list": logs,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// GetStats 获取访问统计 GET /api/v1/admin/access-logs/stats
|
||||
func (h *AccessLogHandler) GetStats(c *gin.Context) {
|
||||
var totalVisits int64
|
||||
h.db.Model(&model.AccessLog{}).Count(&totalVisits)
|
||||
|
||||
var todayVisits int64
|
||||
h.db.Model(&model.AccessLog{}).Where("created_at >= date('now')").Count(&todayVisits)
|
||||
|
||||
var uniqueIPs int64
|
||||
h.db.Model(&model.AccessLog{}).Distinct("ip").Count(&uniqueIPs)
|
||||
|
||||
// 最近 24 小时每小时访问量
|
||||
type hourlyCount struct {
|
||||
Hour string `json:"hour"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
var hourly []hourlyCount
|
||||
h.db.Model(&model.AccessLog{}).
|
||||
Select("strftime('%Y-%m-%d %H:00', created_at) as hour, count(*) as count").
|
||||
Where("created_at >= datetime('now', '-24 hours')").
|
||||
Group("hour").
|
||||
Order("hour ASC").
|
||||
Scan(&hourly)
|
||||
|
||||
// 最近 10 条日志
|
||||
var recent []model.AccessLog
|
||||
h.db.Order("id DESC").Limit(10).Find(&recent)
|
||||
|
||||
Success(c, gin.H{
|
||||
"total_visits": totalVisits,
|
||||
"today_visits": todayVisits,
|
||||
"unique_ips": uniqueIPs,
|
||||
"hourly": hourly,
|
||||
"recent": recent,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteLogs 清空访问日志 DELETE /api/v1/admin/access-logs
|
||||
func (h *AccessLogHandler) DeleteLogs(c *gin.Context) {
|
||||
if err := h.db.Where("1 = 1").Delete(&model.AccessLog{}).Error; err != nil {
|
||||
FailInternal(c, "清空失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
SuccessMsg(c, "清空成功")
|
||||
}
|
||||
82
internal/handler/config.go
Normal file
82
internal/handler/config.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"gotest/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ConfigHandler 系统配置处理器
|
||||
type ConfigHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewConfigHandler 创建系统配置处理器
|
||||
func NewConfigHandler(db *gorm.DB) *ConfigHandler {
|
||||
return &ConfigHandler{db: db}
|
||||
}
|
||||
|
||||
// GetConfig 获取配置 GET /api/v1/admin/config/:key
|
||||
func (h *ConfigHandler) GetConfig(c *gin.Context) {
|
||||
key := c.Param("key")
|
||||
var config model.Config
|
||||
if err := h.db.Where("`key` = ?", key).First(&config).Error; err != nil {
|
||||
Success(c, gin.H{"key": key, "value": "", "remark": ""})
|
||||
return
|
||||
}
|
||||
Success(c, config)
|
||||
}
|
||||
|
||||
// SetConfig 设置配置 PUT /api/v1/admin/config/:key
|
||||
func (h *ConfigHandler) SetConfig(c *gin.Context) {
|
||||
key := c.Param("key")
|
||||
var req struct {
|
||||
Value string `json:"value"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
FailBadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
var config model.Config
|
||||
result := h.db.Where("`key` = ?", key).First(&config)
|
||||
if result.Error == gorm.ErrRecordNotFound {
|
||||
// 创建新配置
|
||||
config = model.Config{
|
||||
Key: key,
|
||||
Value: req.Value,
|
||||
Remark: req.Remark,
|
||||
}
|
||||
if err := h.db.Create(&config).Error; err != nil {
|
||||
FailInternal(c, "创建失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
} else if result.Error == nil {
|
||||
// 更新已有配置
|
||||
config.Value = req.Value
|
||||
if req.Remark != "" {
|
||||
config.Remark = req.Remark
|
||||
}
|
||||
if err := h.db.Save(&config).Error; err != nil {
|
||||
FailInternal(c, "更新失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
FailInternal(c, "数据库错误: "+result.Error.Error())
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, config)
|
||||
}
|
||||
|
||||
// ListConfigs 获取所有配置 GET /api/v1/admin/config
|
||||
func (h *ConfigHandler) ListConfigs(c *gin.Context) {
|
||||
var configs []model.Config
|
||||
if err := h.db.Order("id ASC").Find(&configs).Error; err != nil {
|
||||
FailInternal(c, "查询失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
Success(c, configs)
|
||||
}
|
||||
116
internal/handler/customer.go
Normal file
116
internal/handler/customer.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gotest/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CustomerHandler 客户信息收集处理器
|
||||
type CustomerHandler struct {
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewCustomerHandler 创建客户信息收集处理器
|
||||
func NewCustomerHandler(cfg *config.Config) *CustomerHandler {
|
||||
return &CustomerHandler{cfg: cfg}
|
||||
}
|
||||
|
||||
// CustomerRequest 客户信息请求
|
||||
type CustomerRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
Company string `json:"company"`
|
||||
Email string `json:"email"`
|
||||
Needs string `json:"needs" binding:"required"`
|
||||
}
|
||||
|
||||
// Submit 提交客户信息 POST /api/v1/customer/submit
|
||||
func (h *CustomerHandler) Submit(c *gin.Context) {
|
||||
var req CustomerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
FailBadRequest(c, "请填写完整的联系信息")
|
||||
return
|
||||
}
|
||||
|
||||
// 构建企业微信消息内容
|
||||
content := fmt.Sprintf("【新客户咨询】\n"+
|
||||
"━━━━━━━━━━━━━━━━━━━━\n"+
|
||||
"👤 姓名:%s\n"+
|
||||
"📱 电话:%s\n"+
|
||||
"📧 邮箱:%s\n"+
|
||||
"🏢 公司:%s\n"+
|
||||
"💼 需求:%s\n"+
|
||||
"⏰ 时间:%s\n"+
|
||||
"━━━━━━━━━━━━━━━━━━━━",
|
||||
req.Name, req.Phone, req.Email, req.Company, req.Needs, time.Now().Format("2006-01-02 15:04:05"))
|
||||
|
||||
// 发送到企业微信webhook
|
||||
if err := h.sendToWechatWebhook(content); err != nil {
|
||||
// 即使webhook失败,也记录下来并返回成功(避免暴露内部错误)
|
||||
fmt.Printf("发送企业微信webhook失败: %v\n", err)
|
||||
}
|
||||
|
||||
Success(c, gin.H{
|
||||
"message": "提交成功,我们会尽快联系您!",
|
||||
})
|
||||
}
|
||||
|
||||
// sendToWechatWebhook 发送消息到企业微信群机器人
|
||||
func (h *CustomerHandler) sendToWechatWebhook(content string) error {
|
||||
if h.cfg.WechatWebhook.URL == "" {
|
||||
return fmt.Errorf("企业微信webhook URL未配置")
|
||||
}
|
||||
|
||||
// 构建消息体
|
||||
message := map[string]interface{}{
|
||||
"msgtype": "text",
|
||||
"text": map[string]string{
|
||||
"content": content,
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化消息失败: %w", err)
|
||||
}
|
||||
|
||||
// 发送POST请求
|
||||
resp, err := http.Post(h.cfg.WechatWebhook.URL, "application/json", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("发送请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
|
||||
// 检查响应状态
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("请求失败,状态码: %d,响应: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// 解析响应JSON
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return fmt.Errorf("解析响应失败: %w", err)
|
||||
}
|
||||
|
||||
// 检查企业微信返回的错误码
|
||||
if errcode, ok := result["errcode"].(float64); ok && errcode != 0 {
|
||||
errmsg, _ := result["errmsg"].(string)
|
||||
return fmt.Errorf("企业微信返回错误: %v - %s", errcode, errmsg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -28,7 +28,7 @@ var tableRegistry = map[string]interface{}{
|
||||
"game_games": &model.Game{},
|
||||
"game_categories": &model.GameCategory{},
|
||||
// 应用模块
|
||||
"app_apps": &model.App{},
|
||||
"app_apps": &model.App{},
|
||||
"app_categories": &model.AppCategory{},
|
||||
// 音乐模块
|
||||
"music_songs": &model.Song{},
|
||||
@@ -37,22 +37,22 @@ var tableRegistry = map[string]interface{}{
|
||||
|
||||
// tableLabels 模块中文标签(用于前端展示)
|
||||
var tableLabels = map[string]string{
|
||||
"enterprise_company": "企业信息",
|
||||
"enterprise_news": "企业新闻",
|
||||
"enterprise_services": "企业服务",
|
||||
"enterprise_team": "团队成员",
|
||||
"shop_products": "商品管理",
|
||||
"shop_categories": "商品分类",
|
||||
"food_restaurants": "餐厅管理",
|
||||
"food_categories": "餐厅分类",
|
||||
"food_menu_items": "菜品管理",
|
||||
"enterprise_company": "企业信息",
|
||||
"enterprise_news": "企业新闻",
|
||||
"enterprise_services": "企业服务",
|
||||
"enterprise_team": "团队成员",
|
||||
"shop_products": "商品管理",
|
||||
"shop_categories": "商品分类",
|
||||
"food_restaurants": "餐厅管理",
|
||||
"food_categories": "餐厅分类",
|
||||
"food_menu_items": "菜品管理",
|
||||
"food_menu_categories": "菜品分类",
|
||||
"game_games": "游戏管理",
|
||||
"game_categories": "游戏分类",
|
||||
"app_apps": "应用管理",
|
||||
"app_categories": "应用分类",
|
||||
"music_songs": "歌曲管理",
|
||||
"music_categories": "音乐分类",
|
||||
"game_games": "游戏管理",
|
||||
"game_categories": "游戏分类",
|
||||
"app_apps": "应用管理",
|
||||
"app_categories": "应用分类",
|
||||
"music_songs": "歌曲管理",
|
||||
"music_categories": "音乐分类",
|
||||
}
|
||||
|
||||
// tablesWithSort 拥有 sort 列的表(用于排序)
|
||||
@@ -75,40 +75,40 @@ var tablesWithSort = map[string]bool{
|
||||
|
||||
// tablesWithName 拥有 name 列的表(用于关键词搜索)
|
||||
var tablesWithName = map[string]bool{
|
||||
"enterprise_company": true,
|
||||
"enterprise_team": true,
|
||||
"enterprise_services": false, // 有 title 无 name
|
||||
"enterprise_news": false,
|
||||
"shop_products": true,
|
||||
"shop_categories": true,
|
||||
"food_restaurants": true,
|
||||
"food_categories": true,
|
||||
"food_menu_items": true,
|
||||
"food_menu_categories": true,
|
||||
"game_games": true,
|
||||
"game_categories": true,
|
||||
"app_apps": true,
|
||||
"app_categories": true,
|
||||
"music_songs": false, // 有 title 无 name
|
||||
"music_categories": true,
|
||||
"enterprise_company": true,
|
||||
"enterprise_team": true,
|
||||
"enterprise_services": false, // 有 title 无 name
|
||||
"enterprise_news": false,
|
||||
"shop_products": true,
|
||||
"shop_categories": true,
|
||||
"food_restaurants": true,
|
||||
"food_categories": true,
|
||||
"food_menu_items": true,
|
||||
"food_menu_categories": true,
|
||||
"game_games": true,
|
||||
"game_categories": true,
|
||||
"app_apps": true,
|
||||
"app_categories": true,
|
||||
"music_songs": false, // 有 title 无 name
|
||||
"music_categories": true,
|
||||
}
|
||||
|
||||
// tablesWithTitle 拥有 title 列的表(用于关键词搜索)
|
||||
var tablesWithTitle = map[string]bool{
|
||||
"enterprise_news": true,
|
||||
"enterprise_services": true,
|
||||
"music_songs": true,
|
||||
"enterprise_news": true,
|
||||
"enterprise_services": true,
|
||||
"music_songs": true,
|
||||
}
|
||||
|
||||
// tablesWithStatus 拥有 status 列的表(用于状态过滤)
|
||||
var tablesWithStatus = map[string]bool{
|
||||
"enterprise_news": true,
|
||||
"shop_products": true,
|
||||
"food_restaurants": true,
|
||||
"food_menu_items": true,
|
||||
"game_games": true,
|
||||
"app_apps": true,
|
||||
"music_songs": true,
|
||||
"enterprise_news": true,
|
||||
"shop_products": true,
|
||||
"food_restaurants": true,
|
||||
"food_menu_items": true,
|
||||
"game_games": true,
|
||||
"app_apps": true,
|
||||
"music_songs": true,
|
||||
}
|
||||
|
||||
// DashboardHandler 管理后台仪表盘处理器(通用 CRUD + 统计)
|
||||
@@ -140,7 +140,7 @@ func (h *DashboardHandler) GetStats(c *gin.Context) {
|
||||
// 按业务线分组
|
||||
grouped := gin.H{
|
||||
"enterprise": gin.H{
|
||||
"label": "企业官网",
|
||||
"label": "企业官网",
|
||||
"items": gin.H{
|
||||
"company": stats["enterprise_company"],
|
||||
"news": stats["enterprise_news"],
|
||||
@@ -158,10 +158,10 @@ func (h *DashboardHandler) GetStats(c *gin.Context) {
|
||||
"food": gin.H{
|
||||
"label": "外卖平台",
|
||||
"items": gin.H{
|
||||
"restaurants": stats["food_restaurants"],
|
||||
"categories": stats["food_categories"],
|
||||
"menu_items": stats["food_menu_items"],
|
||||
"menu_categories": stats["food_menu_categories"],
|
||||
"restaurants": stats["food_restaurants"],
|
||||
"categories": stats["food_categories"],
|
||||
"menu_items": stats["food_menu_items"],
|
||||
"menu_categories": stats["food_menu_categories"],
|
||||
},
|
||||
},
|
||||
"game": gin.H{
|
||||
|
||||
78
internal/middleware/access_log.go
Normal file
78
internal/middleware/access_log.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gotest/internal/geoip"
|
||||
"gotest/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// responseWriter 包装 gin.ResponseWriter 以捕获状态码
|
||||
type responseWriter struct {
|
||||
gin.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (w *responseWriter) WriteHeader(code int) {
|
||||
w.statusCode = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// AccessLog 访问日志中间件,记录每次 HTTP 请求到数据库
|
||||
func AccessLog(db *gorm.DB, ipService *geoip.Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 跳过健康检查和静态资源
|
||||
path := c.Request.URL.Path
|
||||
if path == "/api/health" || strings.HasPrefix(path, "/shared/") || strings.HasPrefix(path, "/assets/") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
clientIP := c.ClientIP()
|
||||
|
||||
// 包装响应写入器以捕获状态码
|
||||
rw := &responseWriter{ResponseWriter: c.Writer, statusCode: http.StatusOK}
|
||||
c.Writer = rw
|
||||
|
||||
c.Next()
|
||||
|
||||
// 异步写入日志(不阻塞请求响应)
|
||||
go func() {
|
||||
cost := int(time.Since(start).Milliseconds())
|
||||
ua := c.Request.UserAgent()
|
||||
if len(ua) > 500 {
|
||||
ua = ua[:500]
|
||||
}
|
||||
referer := c.Request.Referer()
|
||||
if len(referer) > 500 {
|
||||
referer = referer[:500]
|
||||
}
|
||||
p := c.Request.URL.Path
|
||||
if len(p) > 500 {
|
||||
p = p[:500]
|
||||
}
|
||||
|
||||
// 查询 IP 归属地
|
||||
location := ipService.Lookup(clientIP)
|
||||
|
||||
log := model.AccessLog{
|
||||
IP: clientIP,
|
||||
Path: p,
|
||||
Method: c.Request.Method,
|
||||
UserAgent: ua,
|
||||
Referer: referer,
|
||||
Location: location,
|
||||
Status: rw.statusCode,
|
||||
Cost: cost,
|
||||
}
|
||||
|
||||
db.Create(&log)
|
||||
}()
|
||||
}
|
||||
}
|
||||
17
internal/model/access_log.go
Normal file
17
internal/model/access_log.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// AccessLog 网站访问日志
|
||||
type AccessLog struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
IP string `gorm:"size:45;index" json:"ip"`
|
||||
Path string `gorm:"size:500;index" json:"path"`
|
||||
Method string `gorm:"size:10" json:"method"`
|
||||
UserAgent string `gorm:"size:500" json:"user_agent"`
|
||||
Referer string `gorm:"size:500" json:"referer"`
|
||||
Location string `gorm:"size:255" json:"location"` // IP 归属地
|
||||
Status int `json:"status"`
|
||||
Cost int `json:"cost"` // 耗时(毫秒)
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
}
|
||||
@@ -19,19 +19,19 @@ type App struct {
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
CategoryID uint `gorm:"index" json:"category_id"`
|
||||
CategoryName string `gorm:"size:50" json:"category_name"`
|
||||
Icon string `gorm:"size:255" json:"icon"` // 应用图标URL
|
||||
Developer string `gorm:"size:100" json:"developer"` // 开发者
|
||||
Icon string `gorm:"size:255" json:"icon"` // 应用图标URL
|
||||
Developer string `gorm:"size:100" json:"developer"` // 开发者
|
||||
Rating float64 `gorm:"type:decimal(2,1);default:5.0" json:"rating"`
|
||||
Reviews int `gorm:"default:0" json:"reviews"` // 评价数
|
||||
Downloads int `gorm:"default:0" json:"downloads"` // 下载量
|
||||
Size string `gorm:"size:50" json:"size"` // 应用大小
|
||||
Version string `gorm:"size:50" json:"version"` // 版本号
|
||||
UpdateDate string `gorm:"size:20" json:"update_date"` // 更新日期
|
||||
Platform string `gorm:"size:100" json:"platform"` // 平台
|
||||
Price string `gorm:"size:50" json:"price"` // 价格(免费/付费)
|
||||
Tags string `gorm:"type:text" json:"tags"` // JSON: 标签数组
|
||||
Screenshots string `gorm:"type:text" json:"screenshots"` // JSON: 截图描述数组
|
||||
Features string `gorm:"type:text" json:"features"` // JSON: 特性列表
|
||||
Reviews int `gorm:"default:0" json:"reviews"` // 评价数
|
||||
Downloads int `gorm:"default:0" json:"downloads"` // 下载量
|
||||
Size string `gorm:"size:50" json:"size"` // 应用大小
|
||||
Version string `gorm:"size:50" json:"version"` // 版本号
|
||||
UpdateDate string `gorm:"size:20" json:"update_date"` // 更新日期
|
||||
Platform string `gorm:"size:100" json:"platform"` // 平台
|
||||
Price string `gorm:"size:50" json:"price"` // 价格(免费/付费)
|
||||
Tags string `gorm:"type:text" json:"tags"` // JSON: 标签数组
|
||||
Screenshots string `gorm:"type:text" json:"screenshots"` // JSON: 截图描述数组
|
||||
Features string `gorm:"type:text" json:"features"` // JSON: 特性列表
|
||||
IsFeatured bool `gorm:"default:false" json:"is_featured"` // 精选
|
||||
IsNew bool `gorm:"default:false" json:"is_new"` // 新上架
|
||||
Sort int `gorm:"default:0" json:"sort"`
|
||||
|
||||
@@ -8,9 +8,9 @@ type Company struct {
|
||||
Name string `gorm:"size:100;not null" json:"name"`
|
||||
Logo string `gorm:"size:255" json:"logo"`
|
||||
Slogan string `gorm:"size:255" json:"slogan"`
|
||||
Description string `gorm:"type:text" json:"description"` // 关于我们
|
||||
Vision string `gorm:"type:text" json:"vision"` // 愿景
|
||||
Mission string `gorm:"type:text" json:"mission"` // 使命
|
||||
Description string `gorm:"type:text" json:"description"` // 关于我们
|
||||
Vision string `gorm:"type:text" json:"vision"` // 愿景
|
||||
Mission string `gorm:"type:text" json:"mission"` // 使命
|
||||
Phone string `gorm:"size:30" json:"phone"`
|
||||
Email string `gorm:"size:100" json:"email"`
|
||||
Address string `gorm:"size:255" json:"address"`
|
||||
@@ -41,8 +41,8 @@ type EnterpriseService struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Title string `gorm:"size:100;not null" json:"title"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
Icon string `gorm:"size:50" json:"icon"` // 图标标识
|
||||
Image string `gorm:"size:255" json:"image"` // 图片URL
|
||||
Icon string `gorm:"size:50" json:"icon"` // 图标标识
|
||||
Image string `gorm:"size:255" json:"image"` // 图片URL
|
||||
Features string `gorm:"type:text" json:"features"` // JSON: 特性列表
|
||||
Sort int `gorm:"default:0" json:"sort"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
@@ -14,25 +14,25 @@ type RestaurantCategory struct {
|
||||
|
||||
// Restaurant 餐厅模型(3 套外卖网站共用同一套数据)
|
||||
type Restaurant struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:100;not null" json:"name"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
CategoryID uint `gorm:"index" json:"category_id"`
|
||||
CategoryName string `gorm:"size:50" json:"category_name"`
|
||||
Cover string `gorm:"size:255" json:"cover"` // 封面图URL
|
||||
Logo string `gorm:"size:255" json:"logo"` // Logo URL
|
||||
Rating float64 `gorm:"type:decimal(2,1);default:4.5" json:"rating"`
|
||||
Sales int `gorm:"default:0" json:"sales"` // 月销量
|
||||
DeliveryFee float64 `gorm:"type:decimal(6,2);default:0" json:"delivery_fee"` // 配送费
|
||||
MinOrder float64 `gorm:"type:decimal(6,2);default:0" json:"min_order"` // 起送价
|
||||
DeliveryTime int `json:"delivery_time"` // 配送时间(分钟)
|
||||
Distance string `gorm:"size:50" json:"distance"` // 距离
|
||||
Tags string `gorm:"type:text" json:"tags"` // JSON: 标签数组
|
||||
Notice string `gorm:"type:text" json:"notice"` // 公告
|
||||
Status int `gorm:"default:1;not null" json:"status"` // 1=营业 0=休息
|
||||
Sort int `gorm:"default:0" json:"sort"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:100;not null" json:"name"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
CategoryID uint `gorm:"index" json:"category_id"`
|
||||
CategoryName string `gorm:"size:50" json:"category_name"`
|
||||
Cover string `gorm:"size:255" json:"cover"` // 封面图URL
|
||||
Logo string `gorm:"size:255" json:"logo"` // Logo URL
|
||||
Rating float64 `gorm:"type:decimal(2,1);default:4.5" json:"rating"`
|
||||
Sales int `gorm:"default:0" json:"sales"` // 月销量
|
||||
DeliveryFee float64 `gorm:"type:decimal(6,2);default:0" json:"delivery_fee"` // 配送费
|
||||
MinOrder float64 `gorm:"type:decimal(6,2);default:0" json:"min_order"` // 起送价
|
||||
DeliveryTime int `json:"delivery_time"` // 配送时间(分钟)
|
||||
Distance string `gorm:"size:50" json:"distance"` // 距离
|
||||
Tags string `gorm:"type:text" json:"tags"` // JSON: 标签数组
|
||||
Notice string `gorm:"type:text" json:"notice"` // 公告
|
||||
Status int `gorm:"default:1;not null" json:"status"` // 1=营业 0=休息
|
||||
Sort int `gorm:"default:0" json:"sort"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// MenuCategory 菜品分类(属于某个餐厅)
|
||||
@@ -47,19 +47,19 @@ type MenuCategory struct {
|
||||
|
||||
// MenuItem 菜品模型
|
||||
type MenuItem struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
RestaurantID uint `gorm:"index;not null" json:"restaurant_id"`
|
||||
CategoryID uint `gorm:"index" json:"category_id"` // 菜品分类ID
|
||||
Name string `gorm:"size:100;not null" json:"name"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
Price float64 `gorm:"type:decimal(8,2);not null" json:"price"`
|
||||
OriginalPrice float64 `gorm:"type:decimal(8,2)" json:"original_price"`
|
||||
Image string `gorm:"size:255" json:"image"`
|
||||
Rating float64 `gorm:"type:decimal(2,1);default:5.0" json:"rating"`
|
||||
Sales int `gorm:"default:0" json:"sales"`
|
||||
Tags string `gorm:"type:text" json:"tags"` // JSON: 标签数组
|
||||
Sort int `gorm:"default:0" json:"sort"`
|
||||
Status int `gorm:"default:1;not null" json:"status"` // 1=上架
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
RestaurantID uint `gorm:"index;not null" json:"restaurant_id"`
|
||||
CategoryID uint `gorm:"index" json:"category_id"` // 菜品分类ID
|
||||
Name string `gorm:"size:100;not null" json:"name"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
Price float64 `gorm:"type:decimal(8,2);not null" json:"price"`
|
||||
OriginalPrice float64 `gorm:"type:decimal(8,2)" json:"original_price"`
|
||||
Image string `gorm:"size:255" json:"image"`
|
||||
Rating float64 `gorm:"type:decimal(2,1);default:5.0" json:"rating"`
|
||||
Sales int `gorm:"default:0" json:"sales"`
|
||||
Tags string `gorm:"type:text" json:"tags"` // JSON: 标签数组
|
||||
Sort int `gorm:"default:0" json:"sort"`
|
||||
Status int `gorm:"default:1;not null" json:"status"` // 1=上架
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -14,28 +14,28 @@ type GameCategory struct {
|
||||
|
||||
// Game 游戏模型(3 套游戏网站共用同一套数据)
|
||||
type Game struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:200;not null" json:"name"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
CategoryID uint `gorm:"index" json:"category_id"`
|
||||
CategoryName string `gorm:"size:50" json:"category_name"`
|
||||
Cover string `gorm:"size:255" json:"cover"` // 封面图URL
|
||||
Developer string `gorm:"size:100" json:"developer"` // 开发商
|
||||
Publisher string `gorm:"size:100" json:"publisher"` // 发行商
|
||||
Rating float64 `gorm:"type:decimal(2,1);default:5.0" json:"rating"`
|
||||
Plays int `gorm:"default:0" json:"plays"` // 游玩次数
|
||||
Downloads int `gorm:"default:0" json:"downloads"` // 下载量
|
||||
Size string `gorm:"size:50" json:"size"` // 游戏大小
|
||||
Version string `gorm:"size:50" json:"version"` // 版本号
|
||||
ReleaseDate string `gorm:"size:20" json:"release_date"` // 发行日期
|
||||
Platform string `gorm:"size:100" json:"platform"` // 平台
|
||||
Tags string `gorm:"type:text" json:"tags"` // JSON: 标签数组
|
||||
Screenshots string `gorm:"type:text" json:"screenshots"` // JSON: 截图描述数组
|
||||
Features string `gorm:"type:text" json:"features"` // JSON: 特性列表
|
||||
IsHot bool `gorm:"default:false" json:"is_hot"` // 热门
|
||||
IsNew bool `gorm:"default:false" json:"is_new"` // 新游
|
||||
Sort int `gorm:"default:0" json:"sort"`
|
||||
Status int `gorm:"default:1;not null" json:"status"` // 1=上架
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:200;not null" json:"name"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
CategoryID uint `gorm:"index" json:"category_id"`
|
||||
CategoryName string `gorm:"size:50" json:"category_name"`
|
||||
Cover string `gorm:"size:255" json:"cover"` // 封面图URL
|
||||
Developer string `gorm:"size:100" json:"developer"` // 开发商
|
||||
Publisher string `gorm:"size:100" json:"publisher"` // 发行商
|
||||
Rating float64 `gorm:"type:decimal(2,1);default:5.0" json:"rating"`
|
||||
Plays int `gorm:"default:0" json:"plays"` // 游玩次数
|
||||
Downloads int `gorm:"default:0" json:"downloads"` // 下载量
|
||||
Size string `gorm:"size:50" json:"size"` // 游戏大小
|
||||
Version string `gorm:"size:50" json:"version"` // 版本号
|
||||
ReleaseDate string `gorm:"size:20" json:"release_date"` // 发行日期
|
||||
Platform string `gorm:"size:100" json:"platform"` // 平台
|
||||
Tags string `gorm:"type:text" json:"tags"` // JSON: 标签数组
|
||||
Screenshots string `gorm:"type:text" json:"screenshots"` // JSON: 截图描述数组
|
||||
Features string `gorm:"type:text" json:"features"` // JSON: 特性列表
|
||||
IsHot bool `gorm:"default:false" json:"is_hot"` // 热门
|
||||
IsNew bool `gorm:"default:false" json:"is_new"` // 新游
|
||||
Sort int `gorm:"default:0" json:"sort"`
|
||||
Status int `gorm:"default:1;not null" json:"status"` // 1=上架
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -15,23 +15,23 @@ type MusicCategory struct {
|
||||
// Song 歌曲模型(3 套音乐网站共用同一套数据)
|
||||
type Song struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Title string `gorm:"size:200;not null" json:"title"` // 歌曲名
|
||||
Artist string `gorm:"size:100;not null" json:"artist"` // 歌手
|
||||
Album string `gorm:"size:200" json:"album"` // 专辑
|
||||
Title string `gorm:"size:200;not null" json:"title"` // 歌曲名
|
||||
Artist string `gorm:"size:100;not null" json:"artist"` // 歌手
|
||||
Album string `gorm:"size:200" json:"album"` // 专辑
|
||||
CategoryID uint `gorm:"index" json:"category_id"`
|
||||
CategoryName string `gorm:"size:50" json:"category_name"` // 曲风
|
||||
Cover string `gorm:"size:255" json:"cover"` // 封面URL
|
||||
Duration int `gorm:"default:0" json:"duration"` // 时长(秒)
|
||||
PlayCount int `gorm:"default:0" json:"play_count"` // 播放量
|
||||
LikeCount int `gorm:"default:0" json:"like_count"` // 收藏量
|
||||
Lyrics string `gorm:"type:text" json:"lyrics"` // 歌词
|
||||
ReleaseDate string `gorm:"size:20" json:"release_date"` // 发行日期
|
||||
Language string `gorm:"size:50" json:"language"` // 语种
|
||||
Tags string `gorm:"type:text" json:"tags"` // JSON: 标签数组
|
||||
IsHot bool `gorm:"default:false" json:"is_hot"` // 热门
|
||||
IsNew bool `gorm:"default:false" json:"is_new"` // 新歌
|
||||
CategoryName string `gorm:"size:50" json:"category_name"` // 曲风
|
||||
Cover string `gorm:"size:255" json:"cover"` // 封面URL
|
||||
Duration int `gorm:"default:0" json:"duration"` // 时长(秒)
|
||||
PlayCount int `gorm:"default:0" json:"play_count"` // 播放量
|
||||
LikeCount int `gorm:"default:0" json:"like_count"` // 收藏量
|
||||
Lyrics string `gorm:"type:text" json:"lyrics"` // 歌词
|
||||
ReleaseDate string `gorm:"size:20" json:"release_date"` // 发行日期
|
||||
Language string `gorm:"size:50" json:"language"` // 语种
|
||||
Tags string `gorm:"type:text" json:"tags"` // JSON: 标签数组
|
||||
IsHot bool `gorm:"default:false" json:"is_hot"` // 热门
|
||||
IsNew bool `gorm:"default:false" json:"is_new"` // 新歌
|
||||
Sort int `gorm:"default:0" json:"sort"`
|
||||
Status int `gorm:"default:1;not null" json:"status"` // 1=上架
|
||||
Status int `gorm:"default:1;not null" json:"status"` // 1=上架
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"size:50;uniqueIndex;not null" json:"username"`
|
||||
Password string `gorm:"size:255;not null" json:"-"` // bcrypt 哈希,不返回前端
|
||||
Phone string `gorm:"size:20;index" json:"phone"` // 手机号
|
||||
Password string `gorm:"size:255;not null" json:"-"` // bcrypt 哈希,不返回前端
|
||||
Phone string `gorm:"size:20;index" json:"phone"` // 手机号
|
||||
Role string `gorm:"size:20;default:user;not null" json:"role"` // admin | user
|
||||
Avatar string `gorm:"size:255" json:"avatar"` // 头像 URL
|
||||
Status int `gorm:"default:1;not null" json:"status"` // 1=正常 0=禁用
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"gotest/internal/config"
|
||||
"gotest/internal/database"
|
||||
"gotest/internal/geoip"
|
||||
"gotest/internal/handler"
|
||||
"gotest/internal/middleware"
|
||||
|
||||
@@ -27,6 +28,10 @@ func Setup(
|
||||
appH *handler.AppHandler,
|
||||
musicH *handler.MusicHandler,
|
||||
dashboardH *handler.DashboardHandler,
|
||||
customerH *handler.CustomerHandler,
|
||||
accessLogH *handler.AccessLogHandler,
|
||||
configH *handler.ConfigHandler,
|
||||
ipLocationService *geoip.Service,
|
||||
) *gin.Engine {
|
||||
if cfg.Env.Name == "prod" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
@@ -34,12 +39,13 @@ func Setup(
|
||||
|
||||
r := gin.New()
|
||||
|
||||
// 全局中间件链(顺序:RequestID → Recover → Logger → CORS → RateLimit)
|
||||
// 全局中间件链(顺序:RequestID → Recover → Logger → CORS → RateLimit → AccessLog)
|
||||
r.Use(middleware.RequestID())
|
||||
r.Use(middleware.Recover())
|
||||
r.Use(middleware.Logger())
|
||||
r.Use(middleware.CORS(&cfg.CORS))
|
||||
r.Use(middleware.RateLimit(&cfg.RateLimit))
|
||||
r.Use(middleware.AccessLog(db.DB, ipLocationService))
|
||||
|
||||
// 健康检查(含数据库就绪探针)
|
||||
r.GET("/api/health", func(c *gin.Context) {
|
||||
@@ -147,6 +153,15 @@ func Setup(
|
||||
music.GET("/new", musicH.ListNew)
|
||||
}
|
||||
|
||||
// 公开接口(无需认证)
|
||||
// ...
|
||||
|
||||
// 客户信息收集(公开,无需认证)
|
||||
customer := api.Group("/customer")
|
||||
{
|
||||
customer.POST("/submit", customerH.Submit)
|
||||
}
|
||||
|
||||
// 需要登录的路由
|
||||
authed := api.Group("")
|
||||
authed.Use(middleware.JWTAuth(cfg.JWT.Secret))
|
||||
@@ -180,6 +195,16 @@ func Setup(
|
||||
admin.POST("/dashboard/:table", dashboardH.CreateItem)
|
||||
admin.PUT("/dashboard/:table/:id", dashboardH.UpdateItem)
|
||||
admin.DELETE("/dashboard/:table/:id", dashboardH.DeleteItem)
|
||||
|
||||
// 访问日志
|
||||
admin.GET("/access-logs", accessLogH.ListLogs)
|
||||
admin.GET("/access-logs/stats", accessLogH.GetStats)
|
||||
admin.DELETE("/access-logs", accessLogH.DeleteLogs)
|
||||
|
||||
// 系统配置(webhook 等)
|
||||
admin.GET("/config", configH.ListConfigs)
|
||||
admin.GET("/config/:key", configH.GetConfig)
|
||||
admin.PUT("/config/:key", configH.SetConfig)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,9 +58,9 @@ func (s *FoodService) GetRestaurantMenu(restaurantID uint) (map[string]interface
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"restaurant": restaurant,
|
||||
"categories": categories,
|
||||
"menu_items": items,
|
||||
"restaurant": restaurant,
|
||||
"categories": categories,
|
||||
"menu_items": items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -76,11 +76,11 @@ func (s *UploadService) Upload(module string, filename string, contentType strin
|
||||
|
||||
// FileInfo 文件信息
|
||||
type FileInfo struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime string `json:"mod_time"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime string `json:"mod_time"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
}
|
||||
|
||||
// ListFiles 列出指定模块下的所有文件(递归遍历子目录)
|
||||
|
||||
9
main.go
9
main.go
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"gotest/internal/config"
|
||||
"gotest/internal/database"
|
||||
"gotest/internal/geoip"
|
||||
"gotest/internal/handler"
|
||||
"gotest/internal/repository"
|
||||
"gotest/internal/router"
|
||||
@@ -88,9 +89,15 @@ func main() {
|
||||
appHandler := handler.NewAppHandler(appService)
|
||||
musicHandler := handler.NewMusicHandler(musicService)
|
||||
dashboardHandler := handler.NewDashboardHandler(db.DB)
|
||||
customerHandler := handler.NewCustomerHandler(cfg)
|
||||
accessLogHandler := handler.NewAccessLogHandler(db.DB)
|
||||
configHandler := handler.NewConfigHandler(db.DB)
|
||||
|
||||
// IP 归属地查询服务
|
||||
ipLocationService := geoip.NewService()
|
||||
|
||||
// 8. 设置路由
|
||||
r := router.Setup(cfg, db, authHandler, userHandler, adminHandler, uploadHandler, enterpriseHandler, shopHandler, foodHandler, gameHandler, appHandler, musicHandler, dashboardHandler)
|
||||
r := router.Setup(cfg, db, authHandler, userHandler, adminHandler, uploadHandler, enterpriseHandler, shopHandler, foodHandler, gameHandler, appHandler, musicHandler, dashboardHandler, customerHandler, accessLogHandler, configHandler, ipLocationService)
|
||||
|
||||
// 9. 配置嵌入式前端:18 套网站模板
|
||||
setupFrontend(r)
|
||||
|
||||
273
web/dist/admin/index.html
vendored
273
web/dist/admin/index.html
vendored
@@ -216,19 +216,38 @@ tbody tr:last-child td{border-bottom:none}
|
||||
<!-- Login Overlay -->
|
||||
<div class="login-overlay" id="loginOverlay">
|
||||
<div class="login-card">
|
||||
<h1>🔐 管理后台</h1>
|
||||
<p class="subtitle">Gotest Admin Dashboard</p>
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" id="loginUsername" placeholder="请输入用户名" value="admin">
|
||||
<h1 id="loginTitle">🔐 管理后台</h1>
|
||||
<p class="subtitle" id="loginSubtitle">Gotest Admin Dashboard</p>
|
||||
<!-- 登录表单 -->
|
||||
<div id="loginForm">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" id="loginUsername" placeholder="请输入用户名">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" id="loginPassword" placeholder="请输入密码">
|
||||
</div>
|
||||
<button class="login-btn" id="loginBtn" onclick="doLogin()">登 录</button>
|
||||
<div class="login-error" id="loginError"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" id="loginPassword" placeholder="请输入密码" value="admin123">
|
||||
<!-- 初始化表单(无管理员时显示) -->
|
||||
<div id="initForm" style="display:none">
|
||||
<div class="form-group">
|
||||
<label>管理员用户名</label>
|
||||
<input type="text" id="initUsername" placeholder="请设置管理员用户名">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>管理员密码</label>
|
||||
<input type="password" id="initPassword" placeholder="请设置密码(至少6位)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>确认密码</label>
|
||||
<input type="password" id="initPasswordConfirm" placeholder="请再次输入密码">
|
||||
</div>
|
||||
<button class="login-btn" id="initBtn" onclick="doInitAdmin()">初始化管理员</button>
|
||||
<div class="login-error" id="initError"></div>
|
||||
</div>
|
||||
<button class="login-btn" id="loginBtn" onclick="doLogin()">登 录</button>
|
||||
<div class="login-error" id="loginError"></div>
|
||||
<div class="login-hint">默认账号: admin / admin123</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -248,6 +267,12 @@ tbody tr:last-child td{border-bottom:none}
|
||||
<span class="icon">📦</span> 内容管理
|
||||
</a>
|
||||
<div class="sidebar-section">系统</div>
|
||||
<a class="sidebar-link" onclick="navigate('accesslog')" data-page="accesslog">
|
||||
<span class="icon">📊</span> 访问日志
|
||||
</a>
|
||||
<a class="sidebar-link" onclick="navigate('webhook')" data-page="webhook">
|
||||
<span class="icon">🔔</span> Webhook配置
|
||||
</a>
|
||||
<a class="sidebar-link" onclick="navigate('settings')" data-page="settings">
|
||||
<span class="icon">⚙️</span> 系统设置
|
||||
</a>
|
||||
@@ -343,6 +368,45 @@ function toast(msg, type = 'info') {
|
||||
}
|
||||
|
||||
// ===== 登录/登出 =====
|
||||
async function checkAdminExists() {
|
||||
try {
|
||||
const resp = await fetch(API_BASE + '/admin/check');
|
||||
const data = await resp.json();
|
||||
return data.data && data.data.initialized;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doInitAdmin() {
|
||||
const btn = document.getElementById('initBtn');
|
||||
const username = document.getElementById('initUsername').value.trim();
|
||||
const password = document.getElementById('initPassword').value;
|
||||
const confirm = document.getElementById('initPasswordConfirm').value;
|
||||
const errEl = document.getElementById('initError');
|
||||
if (!username || !password) { errEl.textContent = '请输入用户名和密码'; return; }
|
||||
if (password.length < 6) { errEl.textContent = '密码至少6位'; return; }
|
||||
if (password !== confirm) { errEl.textContent = '两次密码不一致'; return; }
|
||||
btn.disabled = true; btn.textContent = '初始化中...'; errEl.textContent = '';
|
||||
try {
|
||||
const resp = await fetch(API_BASE + '/admin/init', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.code !== 0) { errEl.textContent = data.message || '初始化失败'; return; }
|
||||
toast('初始化成功,请登录', 'success');
|
||||
document.getElementById('initForm').style.display = 'none';
|
||||
document.getElementById('loginForm').style.display = 'block';
|
||||
document.getElementById('loginSubtitle').textContent = '请输入刚创建的管理员账号';
|
||||
} catch (e) {
|
||||
errEl.textContent = '网络错误,请重试';
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = '初始化管理员';
|
||||
}
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
const btn = document.getElementById('loginBtn');
|
||||
const username = document.getElementById('loginUsername').value.trim();
|
||||
@@ -391,12 +455,14 @@ function navigate(page, params = {}) {
|
||||
document.querySelectorAll('.sidebar-link').forEach(el => el.classList.remove('active'));
|
||||
const link = document.querySelector(`[data-page="${page}"]`);
|
||||
if (link) link.classList.add('active');
|
||||
const titles = { users: '用户管理', content: '内容管理', settings: '系统设置', sites: '站点导航' };
|
||||
const titles = { users: '用户管理', content: '内容管理', settings: '系统设置', sites: '站点导航', accesslog: '访问日志', webhook: 'Webhook配置' };
|
||||
document.getElementById('pageTitle').textContent = titles[page] || page;
|
||||
if (page === 'users') renderUsers();
|
||||
else if (page === 'content') renderContent(params.table);
|
||||
else if (page === 'settings') renderSettings();
|
||||
else if (page === 'sites') renderSites();
|
||||
else if (page === 'accesslog') renderAccessLogs();
|
||||
else if (page === 'webhook') renderWebhook();
|
||||
if (window.innerWidth <= 768) document.getElementById('sidebar').classList.remove('open');
|
||||
}
|
||||
|
||||
@@ -1050,6 +1116,175 @@ function renderSites() {
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
// ===== 访问日志 =====
|
||||
let accessLogPage = 1, accessLogPageSize = 20;
|
||||
async function renderAccessLogs(page) {
|
||||
if (page) accessLogPage = page;
|
||||
const el = document.getElementById('content');
|
||||
el.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
||||
try {
|
||||
const [statsData, logsData] = await Promise.all([
|
||||
api('/admin/access-logs/stats'),
|
||||
api(`/admin/access-logs?page=${accessLogPage}&page_size=${accessLogPageSize}`)
|
||||
]);
|
||||
const stats = statsData.data;
|
||||
const logs = logsData.data;
|
||||
|
||||
let hourlyChart = '';
|
||||
if (stats.hourly && stats.hourly.length > 0) {
|
||||
const maxCount = Math.max(...stats.hourly.map(h => h.count));
|
||||
hourlyChart = stats.hourly.map(h => {
|
||||
const pct = maxCount > 0 ? (h.count / maxCount * 100) : 0;
|
||||
const hour = h.hour ? h.hour.split(' ')[1] || h.hour : '';
|
||||
return `<div style="display:flex;align-items:end;gap:4px;flex-direction:column;align-items:center;flex:1">
|
||||
<div style="font-size:11px;color:var(--text-muted)">${h.count}</div>
|
||||
<div style="width:100%;max-width:32px;background:var(--primary);border-radius:4px 4px 0 0;height:${Math.max(4, pct)}%;min-height:4px;transition:height .3s"></div>
|
||||
<div style="font-size:10px;color:var(--text-muted)">${hour}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="stats-grid" style="margin-bottom:24px">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-blue">📈</div>
|
||||
<div class="stat-value">${stats.total_visits || 0}</div>
|
||||
<div class="stat-label">总访问量</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-green">📅</div>
|
||||
<div class="stat-value">${stats.today_visits || 0}</div>
|
||||
<div class="stat-label">今日访问</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon bg-purple">🌐</div>
|
||||
<div class="stat-value">${stats.unique_ips || 0}</div>
|
||||
<div class="stat-label">独立IP</div>
|
||||
</div>
|
||||
</div>
|
||||
${hourlyChart ? `<div class="table-card" style="margin-bottom:24px">
|
||||
<div class="section-header" style="padding:16px"><h4>📊 最近24小时访问趋势</h4></div>
|
||||
<div style="padding:16px;display:flex;align-items:end;gap:6px;height:180px">${hourlyChart}</div>
|
||||
</div>` : ''}
|
||||
<div class="table-card">
|
||||
<div class="table-toolbar">
|
||||
<h4 style="font-size:14px;font-weight:600">📋 访问记录</h4>
|
||||
<button class="btn btn-danger btn-sm" onclick="clearAccessLogs()">清空日志</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>时间</th><th>IP</th><th>归属地</th><th>路径</th><th>方法</th><th>状态</th><th>耗时</th></tr></thead>
|
||||
<tbody>
|
||||
${logs.list.length === 0 ? '<tr class="empty-row"><td colspan="7">暂无访问记录</td></tr>' : logs.list.map(l => `
|
||||
<tr>
|
||||
<td>${formatDate(l.created_at)}</td>
|
||||
<td><code style="background:#f1f5f9;padding:2px 6px;border-radius:4px;font-size:12px">${l.ip}</code></td>
|
||||
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${l.location||''}">${l.location || '-'}</td>
|
||||
<td style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${l.path}">${l.path}</td>
|
||||
<td><span class="badge-status ${l.method==='GET'?'badge-active':'badge-inactive'}">${l.method}</span></td>
|
||||
<td><span class="badge-status ${l.status<400?'badge-active':'badge-inactive'}">${l.status}</span></td>
|
||||
<td>${l.cost}ms</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
${renderPagination(logs, 'renderAccessLogs')}
|
||||
</div>`;
|
||||
} catch (e) { el.innerHTML = '<div class="loading">加载失败</div>'; }
|
||||
}
|
||||
|
||||
async function clearAccessLogs() {
|
||||
if (!confirm('确定清空所有访问日志?')) return;
|
||||
try {
|
||||
await api('/admin/access-logs', { method: 'DELETE' });
|
||||
toast('清空成功', 'success');
|
||||
renderAccessLogs(1);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ===== Webhook 配置 =====
|
||||
async function renderWebhook() {
|
||||
const el = document.getElementById('content');
|
||||
el.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
||||
try {
|
||||
const data = await api('/admin/config/wechat_webhook_url');
|
||||
const url = data.data ? data.data.value : '';
|
||||
el.innerHTML = `
|
||||
<div class="table-card">
|
||||
<div class="section-header" style="padding:16px"><h4>🔔 企业微信 Webhook 配置</h4></div>
|
||||
<div style="padding:24px;max-width:700px">
|
||||
<div class="form-group">
|
||||
<label>Webhook URL</label>
|
||||
<input type="text" id="webhookUrl" value="${url || ''}" placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" style="width:100%">
|
||||
<div style="font-size:12px;color:var(--text-muted);margin-top:6px">
|
||||
企业微信群机器人的 Webhook 地址,用于接收客户咨询通知和系统告警
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:20px">
|
||||
<button class="btn btn-primary" onclick="saveWebhook()">保存配置</button>
|
||||
<button class="btn btn-outline" onclick="testWebhook()">测试发送</button>
|
||||
</div>
|
||||
<div id="webhookTestResult" style="margin-top:12px;display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-card" style="margin-top:16px">
|
||||
<div class="section-header" style="padding:16px"><h4>📖 使用说明</h4></div>
|
||||
<div style="padding:0 24px 24px">
|
||||
<ol style="line-height:2;color:var(--text-muted);font-size:13px">
|
||||
<li>在企业微信群中添加一个<strong>群机器人</strong></li>
|
||||
<li>点击机器人头像,复制 <strong>Webhook 地址</strong></li>
|
||||
<li>将地址粘贴到上方输入框并保存</li>
|
||||
<li>点击「测试发送」验证配置是否正确</li>
|
||||
<li>配置成功后,客户咨询通知将自动推送到该群</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>`;
|
||||
} catch (e) { el.innerHTML = '<div class="loading">加载失败</div>'; }
|
||||
}
|
||||
|
||||
async function saveWebhook() {
|
||||
const url = document.getElementById('webhookUrl').value.trim();
|
||||
try {
|
||||
await api('/admin/config/wechat_webhook_url', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ value: url, remark: '企业微信群机器人Webhook地址' })
|
||||
});
|
||||
toast('保存成功', 'success');
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function testWebhook() {
|
||||
const resultEl = document.getElementById('webhookTestResult');
|
||||
resultEl.style.display = 'block';
|
||||
resultEl.innerHTML = '<div style="color:var(--text-muted)">发送中...</div>';
|
||||
try {
|
||||
await api('/admin/config/wechat_webhook_url', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ value: document.getElementById('webhookUrl').value.trim(), remark: '企业微信群机器人Webhook地址' })
|
||||
});
|
||||
// 通过客户咨询接口发送测试消息
|
||||
const resp = await fetch(API_BASE + '/customer/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: '测试用户',
|
||||
phone: '13800000000',
|
||||
company: '测试公司',
|
||||
email: 'test@example.com',
|
||||
needs: '这是一条测试消息,用于验证 Webhook 配置是否正确'
|
||||
})
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.code === 0) {
|
||||
resultEl.innerHTML = '<div style="color:var(--success);font-weight:600">✅ 测试消息已发送,请检查企业微信群</div>';
|
||||
} else {
|
||||
resultEl.innerHTML = `<div style="color:var(--danger)">❌ 发送失败: ${data.message}</div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
resultEl.innerHTML = '<div style="color:var(--danger)">❌ 网络错误</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 通用工具 =====
|
||||
function formatDate(d) {
|
||||
if (!d) return '-';
|
||||
@@ -1126,6 +1361,20 @@ document.getElementById('modalOverlay').addEventListener('click', (e) => {
|
||||
document.getElementById('loginPassword').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') doLogin();
|
||||
});
|
||||
document.getElementById('initPasswordConfirm')?.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') doInitAdmin();
|
||||
});
|
||||
|
||||
// 检查管理员是否存在,决定显示登录还是初始化
|
||||
(async function() {
|
||||
const hasAdmin = await checkAdminExists();
|
||||
if (!hasAdmin) {
|
||||
document.getElementById('loginForm').style.display = 'none';
|
||||
document.getElementById('initForm').style.display = 'block';
|
||||
document.getElementById('loginTitle').textContent = '🚀 初始化系统';
|
||||
document.getElementById('loginSubtitle').textContent = '首次使用,请创建管理员账号';
|
||||
}
|
||||
})();
|
||||
|
||||
// 检查登录状态
|
||||
if (token) {
|
||||
|
||||
192
web/dist/index.html
vendored
192
web/dist/index.html
vendored
@@ -4,11 +4,19 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Gotest · 模板导航</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23000'/><text x='50' y='70' font-size='58' font-weight='bold' fill='white' text-anchor='middle' font-family='Arial'>G</text></svg>">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23000'/><text x='50' y='70' font-size='58' font-weight='bold' fill='white' text-anchor='middle' font-family='Arial'>G</text></svg)">
|
||||
<script>
|
||||
// Prevent FOUC: apply theme before render
|
||||
(function(){
|
||||
var t = localStorage.getItem('theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
|
||||
:root{--bg:#f5f5f7;--text:#1d1d1f;--text-sub:#86868b;--card:#fff;--border:rgba(0,0,0,.06);--radius:20px;--shadow:0 4px 24px rgba(0,0,0,.06);--shadow-hover:0 12px 40px rgba(0,0,0,.12)}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
|
||||
:root{--bg:#f5f5f7;--text:#1d1d1f;--text-sub:#86868b;--card:#fff;--border:rgba(0,0,0,.06);--radius:20px;--shadow:0 4px 24px rgba(0,0,0,.06);--shadow-hover:0 12px 40px rgba(0,0,0,.12);--form-bg:rgba(255,255,255,0.95);--input-bg:#f9f9f9;--input-border:#e5e5e5;--admin-btn-bg:#1d1d1f;--admin-btn-hover:#000}
|
||||
[data-theme="dark"]{--bg:#0f0f12;--text:#f5f5f7;--text-sub:#a1a1aa;--card:#1c1c22;--border:rgba(255,255,255,.08);--shadow:0 4px 24px rgba(0,0,0,.3);--shadow-hover:0 12px 40px rgba(0,0,0,.5);--form-bg:rgba(28,28,34,0.95);--input-bg:#27272a;--input-border:#3f3f46;--admin-btn-bg:#f5f5f7;--admin-btn-hover:#fff}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased;transition:background .3s,color .3s}
|
||||
.wrap{max-width:1120px;margin:0 auto;padding:48px 24px 64px}
|
||||
/* Hero */
|
||||
.hero{text-align:center;padding:48px 0 56px}
|
||||
@@ -17,6 +25,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Segoe UI',Ro
|
||||
.hero p{font-size:19px;color:var(--text-sub);max-width:580px;margin:0 auto;line-height:1.5}
|
||||
.hero-pills{display:flex;gap:8px;justify-content:center;flex-wrap:wrap;margin-top:28px}
|
||||
.pill{padding:6px 16px;border-radius:100px;background:#fff;border:1px solid var(--border);font-size:13px;color:var(--text-sub);font-weight:500}
|
||||
[data-theme="dark"] .pill{background:#27272a}
|
||||
.pill b{color:var(--text)}
|
||||
/* Section */
|
||||
.sec{margin-bottom:56px}
|
||||
@@ -46,9 +55,17 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Segoe UI',Ro
|
||||
.g-music .glow{background:linear-gradient(135deg,#312e81 0%,#7c3aed 40%,#f472b6 100%)}
|
||||
/* Admin */
|
||||
.admin-row{display:flex;justify-content:center;margin-top:56px}
|
||||
.admin-btn{display:inline-flex;align-items:center;gap:8px;padding:14px 32px;border-radius:100px;background:#1d1d1f;color:#fff;text-decoration:none;font-size:15px;font-weight:600;transition:all .25s}
|
||||
.admin-btn:hover{background:#000;transform:scale(1.03)}
|
||||
.admin-btn{display:inline-flex;align-items:center;gap:8px;padding:14px 32px;border-radius:100px;background:var(--admin-btn-bg);color:var(--text);text-decoration:none;font-size:15px;font-weight:600;transition:all .25s}
|
||||
.admin-btn:hover{background:var(--admin-btn-hover);transform:scale(1.03)}
|
||||
.admin-btn svg{width:18px;height:18px}
|
||||
/* Theme Toggle */
|
||||
.theme-toggle{position:fixed;top:24px;right:24px;z-index:100;width:48px;height:48px;border-radius:50%;border:2px solid var(--border);background:var(--card);box-shadow:var(--shadow);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .3s}
|
||||
.theme-toggle:hover{transform:scale(1.1);box-shadow:var(--shadow-hover)}
|
||||
.theme-toggle .icon-sun,.theme-toggle .icon-moon{position:absolute;transition:all .3s;font-size:20px}
|
||||
.theme-toggle .icon-sun{opacity:0;transform:rotate(-90deg) scale(.5)}
|
||||
.theme-toggle .icon-moon{opacity:1;transform:rotate(0) scale(1)}
|
||||
[data-theme="dark"] .theme-toggle .icon-sun{opacity:1;transform:rotate(0) scale(1)}
|
||||
[data-theme="dark"] .theme-toggle .icon-moon{opacity:0;transform:rotate(90deg) scale(.5)}
|
||||
/* Footer */
|
||||
.foot{text-align:center;padding:32px 0 0;color:var(--text-sub);font-size:12px}
|
||||
/* Responsive */
|
||||
@@ -58,9 +75,34 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Segoe UI',Ro
|
||||
.item{animation:up .6s ease both}
|
||||
.item:nth-child(1){animation-delay:.05s}.item:nth-child(2){animation-delay:.12s}.item:nth-child(3){animation-delay:.19s}
|
||||
@keyframes up{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:translateY(0)}}
|
||||
/* Consult Section */
|
||||
.consult-section{max-width:680px;margin:64px auto 0;padding:40px;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);border-radius:24px;box-shadow:0 20px 60px rgba(102,126,234,0.3)}
|
||||
.consult-header{text-align:center;margin-bottom:32px}
|
||||
.consult-header h2{font-size:32px;font-weight:700;color:#fff;margin-bottom:8px}
|
||||
.consult-header p{font-size:16px;color:rgba(255,255,255,0.9)}
|
||||
.consult-form{background:var(--form-bg);padding:32px;border-radius:16px;backdrop-filter:blur(10px);transition:background .3s}
|
||||
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px}
|
||||
.form-group{margin-bottom:16px}
|
||||
.form-group label{display:block;font-size:14px;font-weight:600;color:var(--text);margin-bottom:6px}
|
||||
.form-group input,.form-group textarea{width:100%;padding:12px 16px;border:2px solid var(--input-border);border-radius:12px;font-size:15px;transition:all .3s;background:var(--input-bg);color:var(--text)}
|
||||
.form-group input::placeholder,.form-group textarea::placeholder{color:var(--text-sub)}
|
||||
.form-group input:focus,.form-group textarea:focus{outline:none;border-color:#667eea;background:var(--card);box-shadow:0 0 0 4px rgba(102,126,234,0.1)}
|
||||
.form-group textarea{resize:vertical;min-height:100px}
|
||||
.submit-btn{width:100%;padding:16px;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:#fff;border:none;border-radius:12px;font-size:18px;font-weight:700;cursor:pointer;transition:all .3s;margin-top:8px}
|
||||
.submit-btn:hover{transform:translateY(-2px);box-shadow:0 8px 25px rgba(102,126,234,0.4)}
|
||||
.submit-btn:active{transform:translateY(0)}
|
||||
.consult-success{text-align:center;padding:40px;background:var(--form-bg);border-radius:16px;transition:background .3s}
|
||||
.success-icon{width:64px;height:64px;background:linear-gradient(135deg,#10b981 0%,#059669 100%);border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 16px;font-size:32px;color:#fff}
|
||||
.consult-success h3{font-size:24px;font-weight:700;color:var(--text);margin-bottom:8px}
|
||||
.consult-success p{font-size:16px;color:var(--text-sub)}
|
||||
@media(max-width:600px){.form-row{grid-template-columns:1fr}.consult-section{padding:24px;margin:48px 16px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button class="theme-toggle" id="themeToggle" aria-label="切换主题">
|
||||
<span class="icon-sun">☀️</span>
|
||||
<span class="icon-moon">🌙</span>
|
||||
</button>
|
||||
<div class="wrap">
|
||||
<div class="hero">
|
||||
<h1>Gotest <span>模板导航</span></h1>
|
||||
@@ -77,9 +119,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Segoe UI',Ro
|
||||
<div class="sec">
|
||||
<div class="sec-head"><h2>企业官网</h2><span class="sub">3 套风格</span><div class="sec-line"></div></div>
|
||||
<div class="grid">
|
||||
<a class="item" href="/site1"><div class="item-banner g-enterprise"><div class="glow"></div><div class="pattern"></div><span class="item-tag">科技蓝</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">科技企业 · site1</div><div class="item-desc">现代科技公司风格,蓝色主调</div></div></a>
|
||||
<a class="item" href="/site2"><div class="item-banner g-enterprise"><div class="glow" style="background:linear-gradient(135deg,#1c1917 0%,#b45309 40%,#fbbf24 100%)"></div><div class="pattern"></div><span class="item-tag">制造金</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">制造企业 · site2</div><div class="item-desc">工业制造风格,深色金调</div></div></a>
|
||||
<a class="item" href="/site3"><div class="item-banner g-enterprise"><div class="glow" style="background:linear-gradient(135deg,#064e3b 0%,#10b981 40%,#6ee7b7 100%)"></div><div class="pattern"></div><span class="item-tag">咨询绿</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">咨询服务 · site3</div><div class="item-desc">专业咨询风格,绿色清新</div></div></a>
|
||||
<a class="item" href="/site1" target="_blank"><div class="item-banner g-enterprise"><div class="glow"></div><div class="pattern"></div><span class="item-tag">科技蓝</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">科技企业 · site1</div><div class="item-desc">现代科技公司风格,蓝色主调</div></div></a>
|
||||
<a class="item" href="/site2" target="_blank"><div class="item-banner g-enterprise"><div class="glow" style="background:linear-gradient(135deg,#1c1917 0%,#b45309 40%,#fbbf24 100%)"></div><div class="pattern"></div><span class="item-tag">制造金</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">制造企业 · site2</div><div class="item-desc">工业制造风格,深色金调</div></div></a>
|
||||
<a class="item" href="/site3" target="_blank"><div class="item-banner g-enterprise"><div class="glow" style="background:linear-gradient(135deg,#064e3b 0%,#10b981 40%,#6ee7b7 100%)"></div><div class="pattern"></div><span class="item-tag">咨询绿</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">咨询服务 · site3</div><div class="item-desc">专业咨询风格,绿色清新</div></div></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,10 +129,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Segoe UI',Ro
|
||||
<div class="sec">
|
||||
<div class="sec-head"><h2>跨境电商</h2><span class="sub">4 套风格</span><div class="sec-line"></div></div>
|
||||
<div class="grid">
|
||||
<a class="item" href="/shop"><div class="item-banner g-shop"><div class="glow"></div><div class="pattern"></div><span class="item-tag">红色经典</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">经典电商 · shop</div><div class="item-desc">经典电商红,大促风格</div></div></a>
|
||||
<a class="item" href="/shop2"><div class="item-banner g-shop"><div class="glow" style="background:linear-gradient(135deg,#18181b 0%,#3f3f46 40%,#71717a 100%)"></div><div class="pattern"></div><span class="item-tag">暗黑模式</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">暗黑电商 · shop2</div><div class="item-desc">黑色高端,极简暗色</div></div></a>
|
||||
<a class="item" href="/shop3"><div class="item-banner g-shop"><div class="glow" style="background:linear-gradient(135deg,#f5f5f0 0%,#d6d3d1 40%,#a8a29e 100%)"></div><div class="pattern" style="opacity:.08"></div><span class="item-tag" style="background:rgba(0,0,0,.1);color:#1c1917;border-color:rgba(0,0,0,.1)">北欧极简</span><div class="item-go" style="color:#1c1917">→</div></div><div class="item-body"><div class="item-title">北欧极简 · shop3</div><div class="item-desc">NORDI 风格,自然柔和</div></div></a>
|
||||
<a class="item" href="/shop4"><div class="item-banner g-shop"><div class="glow" style="background:linear-gradient(135deg,#0f172a 0%,#1a3a2a 50%,#2d5a1f 100%)"></div><div class="pattern" style="opacity:.06"></div><span class="item-tag">古树生机</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">古树商城 · shop4</div><div class="item-desc">古树壁纸,光追生命力</div></div></a>
|
||||
<a class="item" href="/shop" target="_blank"><div class="item-banner g-shop"><div class="glow"></div><div class="pattern"></div><span class="item-tag">红色经典</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">经典电商 · shop</div><div class="item-desc">经典电商红,大促风格</div></div></a>
|
||||
<a class="item" href="/shop2" target="_blank"><div class="item-banner g-shop"><div class="glow" style="background:linear-gradient(135deg,#18181b 0%,#3f3f46 40%,#71717a 100%)"></div><div class="pattern"></div><span class="item-tag">暗黑模式</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">暗黑电商 · shop2</div><div class="item-desc">黑色高端,极简暗色</div></div></a>
|
||||
<a class="item" href="/shop3" target="_blank"><div class="item-banner g-shop"><div class="glow" style="background:linear-gradient(135deg,#f5f5f0 0%,#d6d3d1 40%,#a8a29e 100%)"></div><div class="pattern" style="opacity:.08"></div><span class="item-tag" style="background:rgba(0,0,0,.1);color:#1c1917;border-color:rgba(0,0,0,.1)">北欧极简</span><div class="item-go" style="color:#1c1917">→</div></div><div class="item-body"><div class="item-title">北欧极简 · shop3</div><div class="item-desc">NORDI 风格,自然柔和</div></div></a>
|
||||
<a class="item" href="/shop4" target="_blank"><div class="item-banner g-shop"><div class="glow" style="background:linear-gradient(135deg,#0f172a 0%,#1a3a2a 50%,#2d5a1f 100%)"></div><div class="pattern" style="opacity:.06"></div><span class="item-tag">古树生机</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">古树商城 · shop4</div><div class="item-desc">古树壁纸,光追生命力</div></div></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -98,9 +140,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Segoe UI',Ro
|
||||
<div class="sec">
|
||||
<div class="sec-head"><h2>外卖平台</h2><span class="sub">3 套风格</span><div class="sec-line"></div></div>
|
||||
<div class="grid">
|
||||
<a class="item" href="/food1"><div class="item-banner g-food"><div class="glow"></div><div class="pattern"></div><span class="item-tag">橙黄活力</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">美味达 · food1</div><div class="item-desc">暖橙食欲风格,活力满满</div></div></a>
|
||||
<a class="item" href="/food2"><div class="item-banner g-food"><div class="glow" style="background:linear-gradient(135deg,#18181b 0%,#44403c 40%,#78716c 100%)"></div><div class="pattern"></div><span class="item-tag">深色模式</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">GOURMET · food2</div><div class="item-desc">暗色高端外卖平台</div></div></a>
|
||||
<a class="item" href="/food3"><div class="item-banner g-food"><div class="glow" style="background:linear-gradient(135deg,#14532d 0%,#22c55e 40%,#86efac 100%)"></div><div class="pattern"></div><span class="item-tag">清新自然</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">绿野鲜送 · food3</div><div class="item-desc">绿色健康,清新自然</div></div></a>
|
||||
<a class="item" href="/food1" target="_blank"><div class="item-banner g-food"><div class="glow"></div><div class="pattern"></div><span class="item-tag">橙黄活力</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">美味达 · food1</div><div class="item-desc">暖橙食欲风格,活力满满</div></div></a>
|
||||
<a class="item" href="/food2" target="_blank"><div class="item-banner g-food"><div class="glow" style="background:linear-gradient(135deg,#18181b 0%,#44403c 40%,#78716c 100%)"></div><div class="pattern"></div><span class="item-tag">深色模式</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">GOURMET · food2</div><div class="item-desc">暗色高端外卖平台</div></div></a>
|
||||
<a class="item" href="/food3" target="_blank"><div class="item-banner g-food"><div class="glow" style="background:linear-gradient(135deg,#14532d 0%,#22c55e 40%,#86efac 100%)"></div><div class="pattern"></div><span class="item-tag">清新自然</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">绿野鲜送 · food3</div><div class="item-desc">绿色健康,清新自然</div></div></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -108,9 +150,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Segoe UI',Ro
|
||||
<div class="sec">
|
||||
<div class="sec-head"><h2>游戏平台</h2><span class="sub">3 套风格</span><div class="sec-line"></div></div>
|
||||
<div class="grid">
|
||||
<a class="item" href="/game1"><div class="item-banner g-game"><div class="glow"></div><div class="pattern"></div><span class="item-tag">赛博朋克</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">NEON ARCADE · game1</div><div class="item-desc">霓虹未来,赛博朋克风格</div></div></a>
|
||||
<a class="item" href="/game2"><div class="item-banner g-game"><div class="glow" style="background:linear-gradient(135deg,#052e16 0%,#16a34a 40%,#4ade80 100%)"></div><div class="pattern"></div><span class="item-tag">像素复古</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">PIXEL GAMES · game2</div><div class="item-desc">8-bit 像素复古游戏风</div></div></a>
|
||||
<a class="item" href="/game3"><div class="item-banner g-game"><div class="glow" style="background:linear-gradient(135deg,#083344 0%,#06b6d4 40%,#67e8f9 100%)"></div><div class="pattern"></div><span class="item-tag">云游戏</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">云游平台 · game3</div><div class="item-desc">流线科技,云游戏风格</div></div></a>
|
||||
<a class="item" href="/game1" target="_blank"><div class="item-banner g-game"><div class="glow"></div><div class="pattern"></div><span class="item-tag">赛博朋克</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">NEON ARCADE · game1</div><div class="item-desc">霓虹未来,赛博朋克风格</div></div></a>
|
||||
<a class="item" href="/game2" target="_blank"><div class="item-banner g-game"><div class="glow" style="background:linear-gradient(135deg,#052e16 0%,#16a34a 40%,#4ade80 100%)"></div><div class="pattern"></div><span class="item-tag">像素复古</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">PIXEL GAMES · game2</div><div class="item-desc">8-bit 像素复古游戏风</div></div></a>
|
||||
<a class="item" href="/game3" target="_blank"><div class="item-banner g-game"><div class="glow" style="background:linear-gradient(135deg,#083344 0%,#06b6d4 40%,#67e8f9 100%)"></div><div class="pattern"></div><span class="item-tag">云游戏</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">云游平台 · game3</div><div class="item-desc">流线科技,云游戏风格</div></div></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -118,9 +160,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Segoe UI',Ro
|
||||
<div class="sec">
|
||||
<div class="sec-head"><h2>应用商店</h2><span class="sub">3 套风格</span><div class="sec-line"></div></div>
|
||||
<div class="grid">
|
||||
<a class="item" href="/app1"><div class="item-banner g-app"><div class="glow" style="background:linear-gradient(135deg,#f8fafc 0%,#e2e8f0 40%,#cbd5e1 100%)"></div><div class="pattern" style="opacity:.06"></div><span class="item-tag" style="background:rgba(0,0,0,.08);color:#1e293b;border-color:rgba(0,0,0,.08)">极简白</span><div class="item-go" style="color:#1e293b">→</div></div><div class="item-body"><div class="item-title">极简白 · app1</div><div class="item-desc">Apple 风格,简洁清爽</div></div></a>
|
||||
<a class="item" href="/app2"><div class="item-banner g-app"><div class="glow" style="background:linear-gradient(135deg,#052e16 0%,#10b981 40%,#34d399 100%)"></div><div class="pattern"></div><span class="item-tag">暗黑模式</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">暗黑应用 · app2</div><div class="item-desc">霓虹绿暗色,科技感十足</div></div></a>
|
||||
<a class="item" href="/app3"><div class="item-banner g-app"><div class="glow"></div><div class="pattern"></div><span class="item-tag">清新蓝</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">清新蓝 · app3</div><div class="item-desc">明亮配色,活泼现代</div></div></a>
|
||||
<a class="item" href="/app1" target="_blank"><div class="item-banner g-app"><div class="glow" style="background:linear-gradient(135deg,#f8fafc 0%,#e2e8f0 40%,#cbd5e1 100%)"></div><div class="pattern" style="opacity:.06"></div><span class="item-tag" style="background:rgba(0,0,0,.08);color:#1e293b;border-color:rgba(0,0,0,.08)">极简白</span><div class="item-go" style="color:#1e293b">→</div></div><div class="item-body"><div class="item-title">极简白 · app1</div><div class="item-desc">Apple 风格,简洁清爽</div></div></a>
|
||||
<a class="item" href="/app2" target="_blank"><div class="item-banner g-app"><div class="glow" style="background:linear-gradient(135deg,#052e16 0%,#10b981 40%,#34d399 100%)"></div><div class="pattern"></div><span class="item-tag">暗黑模式</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">暗黑应用 · app2</div><div class="item-desc">霓虹绿暗色,科技感十足</div></div></a>
|
||||
<a class="item" href="/app3" target="_blank"><div class="item-banner g-app"><div class="glow"></div><div class="pattern"></div><span class="item-tag">清新蓝</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">清新蓝 · app3</div><div class="item-desc">明亮配色,活泼现代</div></div></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -128,9 +170,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Segoe UI',Ro
|
||||
<div class="sec">
|
||||
<div class="sec-head"><h2>音乐平台</h2><span class="sub">3 套风格</span><div class="sec-line"></div></div>
|
||||
<div class="grid">
|
||||
<a class="item" href="/music1"><div class="item-banner g-music"><div class="glow" style="background:linear-gradient(135deg,#1e1b4b 0%,#4f46e5 40%,#818cf8 100%)"></div><div class="pattern"></div><span class="item-tag">暗黑播放器</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">云音 Music · music1</div><div class="item-desc">暗夜沉浸式音乐播放器</div></div></a>
|
||||
<a class="item" href="/music2"><div class="item-banner g-music"><div class="glow" style="background:linear-gradient(135deg,#451a03 0%,#92400e 40%,#d97706 100%)"></div><div class="pattern"></div><span class="item-tag">黑胶复古</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">Vinyl · music2</div><div class="item-desc">黑胶唱片复古风格</div></div></a>
|
||||
<a class="item" href="/music3"><div class="item-banner g-music"><div class="glow"></div><div class="pattern"></div><span class="item-tag">渐变流媒体</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">流媒体 · music3</div><div class="item-desc">紫粉渐变,现代流媒体</div></div></a>
|
||||
<a class="item" href="/music1" target="_blank"><div class="item-banner g-music"><div class="glow" style="background:linear-gradient(135deg,#1e1b4b 0%,#4f46e5 40%,#818cf8 100%)"></div><div class="pattern"></div><span class="item-tag">暗黑播放器</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">云音 Music · music1</div><div class="item-desc">暗夜沉浸式音乐播放器</div></div></a>
|
||||
<a class="item" href="/music2" target="_blank"><div class="item-banner g-music"><div class="glow" style="background:linear-gradient(135deg,#451a03 0%,#92400e 40%,#d97706 100%)"></div><div class="pattern"></div><span class="item-tag">黑胶复古</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">Vinyl · music2</div><div class="item-desc">黑胶唱片复古风格</div></div></a>
|
||||
<a class="item" href="/music3" target="_blank"><div class="item-banner g-music"><div class="glow"></div><div class="pattern"></div><span class="item-tag">渐变流媒体</span><div class="item-go">→</div></div><div class="item-body"><div class="item-title">流媒体 · music3</div><div class="item-desc">紫粉渐变,现代流媒体</div></div></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -142,7 +184,109 @@ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Segoe UI',Ro
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 客户咨询表单 -->
|
||||
<div class="consult-section">
|
||||
<div class="consult-header">
|
||||
<h2>需要定制网站?</h2>
|
||||
<p>只需几百块,即可拥有同款精美站点!留下您的信息,我们尽快联系您</p>
|
||||
</div>
|
||||
<form class="consult-form" id="consultForm">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="name">姓名 *</label>
|
||||
<input type="text" id="name" name="name" placeholder="请输入您的姓名" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="phone">联系电话 *</label>
|
||||
<input type="tel" id="phone" name="phone" placeholder="请输入您的手机号" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="company">公司名称</label>
|
||||
<input type="text" id="company" name="company" placeholder="请输入公司名称(选填)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">邮箱</label>
|
||||
<input type="email" id="email" name="email" placeholder="请输入邮箱(选填)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="needs">需求描述 *</label>
|
||||
<textarea id="needs" name="needs" rows="4" placeholder="请描述您的需求,比如:想要一个企业官网,类似科技风格..." required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="submit-btn" id="submitBtn">
|
||||
<span class="btn-text">立即咨询</span>
|
||||
<span class="btn-loading" style="display:none">提交中...</span>
|
||||
</button>
|
||||
</form>
|
||||
<div class="consult-success" id="consultSuccess" style="display:none">
|
||||
<div class="success-icon">✓</div>
|
||||
<h3>提交成功!</h3>
|
||||
<p>我们会尽快联系您,请保持电话畅通</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="foot">Gotest · 多行业网站模板集合 · 点击卡片进入对应模板</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Theme toggle
|
||||
(function(){
|
||||
var toggle = document.getElementById('themeToggle');
|
||||
toggle.addEventListener('click', function(){
|
||||
var current = document.documentElement.getAttribute('data-theme');
|
||||
var next = current === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('theme', next);
|
||||
});
|
||||
})();
|
||||
|
||||
document.getElementById('consultForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const btnText = submitBtn.querySelector('.btn-text');
|
||||
const btnLoading = submitBtn.querySelector('.btn-loading');
|
||||
|
||||
btnText.style.display = 'none';
|
||||
btnLoading.style.display = 'inline';
|
||||
submitBtn.disabled = true;
|
||||
|
||||
const formData = {
|
||||
name: document.getElementById('name').value,
|
||||
phone: document.getElementById('phone').value,
|
||||
company: document.getElementById('company').value,
|
||||
email: document.getElementById('email').value,
|
||||
needs: document.getElementById('needs').value
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/customer/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
document.getElementById('consultForm').style.display = 'none';
|
||||
document.getElementById('consultSuccess').style.display = 'block';
|
||||
} else {
|
||||
alert(result.message || '提交失败,请稍后重试');
|
||||
btnText.style.display = 'inline';
|
||||
btnLoading.style.display = 'none';
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error);
|
||||
alert('网络错误,请稍后重试');
|
||||
btnText.style.display = 'inline';
|
||||
btnLoading.style.display = 'none';
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user