代码推送

This commit is contained in:
2026-09-09 02:39:57 +08:00
parent a112893947
commit 5e47757c73
24 changed files with 1156 additions and 226 deletions

View File

@@ -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:

View File

@@ -16,6 +16,7 @@ type Config struct {
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"`
@@ -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"`

View File

@@ -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
}

View File

@@ -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
View 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, " ")
}

View 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, "清空成功")
}

View 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)
}

View 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
}

View 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)
}()
}
}

View 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"`
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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>
<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="请输入用户名" value="admin">
<input type="text" id="loginUsername" placeholder="请输入用户名">
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="loginPassword" placeholder="请输入密码" value="admin123">
<input type="password" id="loginPassword" placeholder="请输入密码">
</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 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>
</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
View File

@@ -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>