New warehouse

This commit is contained in:
2025-10-24 00:09:45 +08:00
commit ac07e27908
75 changed files with 26814 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
networkDev
node.txt
recharge.db
config.json
data.db

17
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,17 @@
{
// 使用 IntelliSense 了解相关属性。
// 悬停以查看现有属性的描述。
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch Package",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"args": ["server"]
}
]
}

111
cmd/root.go Normal file
View File

@@ -0,0 +1,111 @@
package cmd
import (
"io"
"networkDev/config"
"networkDev/utils/logger"
"os"
"path/filepath"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var cfgFile string
// rootCmd 代表没有调用子命令时的基础命令
var rootCmd = &cobra.Command{
Use: "networkDev",
Short: "一个基于Cobra的网络验证服务器应用",
Long: `networkDev是一个使用Cobra CLI框架构建的网络验证服务器应用
集成了Viper配置管理、Logrus日志记录和embed静态资源嵌入功能。`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
// 在加载配置前配置logrus用于非HTTP日志
setupLogrusForNonHTTP()
},
}
// Execute 添加所有子命令到根命令并设置适当的标志
// 这由main.main()调用。只需要对rootCmd执行一次。
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
cobra.OnInitialize(initConfig)
// 在这里定义标志和配置设置
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "配置文件路径 (默认为 config.json)")
}
// setupLogrusForNonHTTP 配置logrus用于非HTTP日志
// 在加载配置文件之前进行基本的logrus设置
func setupLogrusForNonHTTP() {
// 设置日志格式
logrus.SetFormatter(&logrus.TextFormatter{
TimestampFormat: "2006-01-02 15:04:05",
FullTimestamp: true,
ForceColors: false,
DisableColors: true,
})
// 设置默认日志级别
logrus.SetLevel(logrus.InfoLevel)
// 设置输出目标(稍后会根据配置文件调整)
logrus.SetOutput(os.Stdout)
if cfgFile != "" {
// 使用命令行指定的配置文件
config.Init(cfgFile)
} else {
// 使用默认配置文件路径
config.Init("./config.json")
}
// 根据配置文件进一步配置logrus
setupLogrusFromConfig()
// 初始化HTTP日志处理器
logger.InitLogger()
// 记录配置加载完成
logrus.WithField("config_file", viper.ConfigFileUsed()).Info("配置文件加载完成")
}
// initConfig 读取配置文件和环境变量
func initConfig() {
}
// setupLogrusFromConfig 根据配置文件进一步配置logrus
// 设置日志级别和输出目标
func setupLogrusFromConfig() {
// 设置日志级别
if level := viper.GetString("log.level"); level != "" {
if logLevel, err := logrus.ParseLevel(level); err == nil {
logrus.SetLevel(logLevel)
}
}
// 设置日志输出目标
logFile := viper.GetString("log.file")
if logFile != "" {
// 确保日志目录存在
logDir := filepath.Dir(logFile)
if err := os.MkdirAll(logDir, 0755); err == nil {
// 打开日志文件
if file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
// 同时输出到控制台和文件
multiWriter := io.MultiWriter(os.Stdout, file)
logrus.SetOutput(multiWriter)
}
}
}
// 当日志文件路径为空时,保持默认输出到控制台,不创建任何目录
}

149
cmd/server.go Normal file
View File

@@ -0,0 +1,149 @@
package cmd
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"networkDev/database"
"networkDev/middleware"
"networkDev/server"
"networkDev/utils"
"networkDev/utils/logger"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// serverCmd 代表服务器命令
var serverCmd = &cobra.Command{
Use: "server",
Short: "启动HTTP服务器",
Long: `启动一个简单的HTTP服务器监听配置文件中指定的端口。`,
Run: runServer,
}
func init() {
// 将服务器命令添加到根命令
rootCmd.AddCommand(serverCmd)
// 添加服务器特定的标志
serverCmd.Flags().StringP("host", "H", "", "服务器监听地址 (覆盖配置文件)")
serverCmd.Flags().IntP("port", "p", 0, "服务器监听端口 (覆盖配置文件)")
}
// runServer 运行HTTP服务器
func runServer(cmd *cobra.Command, args []string) {
// 获取配置
host := getServerHost(cmd)
port := getServerPort(cmd)
addr := fmt.Sprintf("%s:%d", host, port)
// 获取全局日志实例
logger := logger.GetLogger()
logger.LogServerStart(host, port)
// 初始化Redis如果配置存在失败不致命
utils.InitRedis()
// 初始化数据库(根据 viper 配置选择 SQLite 或 MySQL
// 如果初始化失败则回退并退出
if _, err := database.Init(); err != nil {
logrus.WithError(err).Fatal("数据库初始化失败")
}
// 执行自动迁移(确保表结构存在)
if err := database.AutoMigrate(); err != nil {
logrus.WithError(err).Fatal("数据库自动迁移失败")
}
// 初始化默认管理员账号admin/admin123
if err := database.SeedDefaultAdmin(); err != nil {
logrus.WithError(err).Fatal("默认管理员初始化失败")
}
// 初始化默认系统设置
if err := database.SeedDefaultSettings(); err != nil {
logrus.WithError(err).Fatal("默认系统设置初始化失败")
}
// 创建HTTP服务器
server := createHTTPServer(addr)
// 启动服务器
startServer(server)
}
// getServerHost 获取服务器监听地址
func getServerHost(cmd *cobra.Command) string {
if host, _ := cmd.Flags().GetString("host"); host != "" {
return host
}
return viper.GetString("server.host")
}
// getServerPort 获取服务器监听端口
func getServerPort(cmd *cobra.Command) int {
if port, _ := cmd.Flags().GetInt("port"); port != 0 {
return port
}
return viper.GetInt("server.port")
}
// createHTTPServer 创建HTTP服务器
func createHTTPServer(addr string) *http.Server {
mux := http.NewServeMux()
// 注册路由
registerRoutes(mux)
// 使用日志中间件包装处理器
handler := middleware.WrapHandler(mux)
return &http.Server{
Addr: addr,
Handler: handler,
}
}
// registerRoutes 注册HTTP路由
func registerRoutes(mux *http.ServeMux) {
// 使用server包中的路由注册函数
server.RegisterRoutes(mux)
}
// startServer 启动服务器并处理优雅关闭
func startServer(server *http.Server) {
// 获取全局日志实例
logger := logger.GetLogger()
// 创建一个通道来接收操作系统信号
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
// 在goroutine中启动服务器
go func() {
logger.WithField("addr", server.Addr).Info("HTTP服务器已启动")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.LogError(err, "服务器启动失败")
os.Exit(1)
}
}()
// 等待中断信号
<-sigChan
logger.Info("收到关闭信号,正在优雅关闭服务器...")
// 创建一个带超时的上下文
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 优雅关闭服务器
if err := server.Shutdown(ctx); err != nil {
logger.LogError(err, "服务器关闭时出错")
} else {
logger.LogServerStop()
}
}

79
config/config.go Normal file
View File

@@ -0,0 +1,79 @@
package config
import (
"bytes"
_ "embed"
"errors"
"io/fs"
"os"
log "github.com/sirupsen/logrus"
"github.com/spf13/viper"
)
//go:embed config.json
var DefaultConfig string
// Init 初始化配置文件
func Init(cfgFilePath string) {
viper.SetConfigFile(cfgFilePath)
viper.SetConfigType("json")
viper.AddConfigPath(".")
if err := viper.ReadInConfig(); err != nil {
var pathError *fs.PathError
if errors.As(err, &pathError) {
log.Warn("未找到配置文件,使用默认配置")
err = os.WriteFile(cfgFilePath, []byte(DefaultConfig), 0o644)
if err != nil {
log.WithFields(
log.Fields{
"err": err,
},
).Error("写入默认配置文件失败")
} else {
log.WithFields(
log.Fields{
"file": cfgFilePath,
},
).Info("写入默认配置文件成功")
}
// 写完默认配置后再读一次
err = viper.ReadConfig(bytes.NewBuffer([]byte(DefaultConfig)))
if err != nil {
log.WithFields(
log.Fields{
"err": err,
},
).Error("读取默认配置文件失败")
} else {
log.Info("已成功读取默认配置")
}
} else {
log.WithFields(
log.Fields{
"err": err,
},
).Fatal("配置文件解析错误")
}
}
log.WithFields(
log.Fields{
"file": viper.ConfigFileUsed(),
},
).Info("使用配置文件")
// 验证配置并设置默认值
if _, err := ValidateAndSetDefaults(); err != nil {
log.WithFields(
log.Fields{
"err": err,
},
).Fatal("配置验证失败")
}
}
// CreateDefaultConfig 创建默认配置文件
func CreateDefaultConfig(filePath string) error {
return os.WriteFile(filePath, []byte(DefaultConfig), 0o644)
}

410
config/validator.go Normal file
View File

@@ -0,0 +1,410 @@
package config
import (
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
log "github.com/sirupsen/logrus"
"github.com/spf13/viper"
)
// ServerConfig 服务器配置结构体
// 包含HTTP服务器的基本配置信息
type ServerConfig struct {
Host string `json:"host" mapstructure:"host"` // 服务器监听地址
Port int `json:"port" mapstructure:"port"` // 服务器监听端口
Mode string `json:"mode" mapstructure:"mode"` // 运行模式debug/release
Dist string `json:"dist" mapstructure:"dist"` // 静态文件目录
}
// DatabaseConfig 数据库配置结构体
// 支持MySQL和SQLite两种数据库类型
type DatabaseConfig struct {
Type string `json:"type" mapstructure:"type"` // 数据库类型mysql/sqlite
MySQL MySQLConfig `json:"mysql" mapstructure:"mysql"` // MySQL配置
SQLite SQLiteConfig `json:"sqlite" mapstructure:"sqlite"` // SQLite配置
}
// MySQLConfig MySQL数据库配置结构体
// 包含MySQL数据库连接和连接池的配置信息
type MySQLConfig struct {
Host string `json:"host" mapstructure:"host"` // 数据库主机地址
Port int `json:"port" mapstructure:"port"` // 数据库端口
Username string `json:"username" mapstructure:"username"` // 数据库用户名
Password string `json:"password" mapstructure:"password"` // 数据库密码
Database string `json:"database" mapstructure:"database"` // 数据库名称
Charset string `json:"charset" mapstructure:"charset"` // 字符集
MaxIdleConns int `json:"max_idle_conns" mapstructure:"max_idle_conns"` // 最大空闲连接数
MaxOpenConns int `json:"max_open_conns" mapstructure:"max_open_conns"` // 最大打开连接数
}
// SQLiteConfig SQLite数据库配置结构体
// 包含SQLite数据库文件路径配置
type SQLiteConfig struct {
Path string `json:"path" mapstructure:"path"` // 数据库文件路径
}
// RedisConfig Redis配置结构体
// 包含Redis缓存服务器的连接配置
type RedisConfig struct {
Host string `json:"host" mapstructure:"host"` // Redis服务器地址
Port int `json:"port" mapstructure:"port"` // Redis服务器端口
Password string `json:"password" mapstructure:"password"` // Redis密码
DB int `json:"db" mapstructure:"db"` // Redis数据库编号
}
// LogConfig 日志配置结构体
// 包含日志记录的相关配置信息
type LogConfig struct {
Level string `json:"level" mapstructure:"level"` // 日志级别
File string `json:"file" mapstructure:"file"` // 日志文件路径
MaxSize int `json:"max_size" mapstructure:"max_size"` // 单个日志文件最大大小(MB)
MaxBackups int `json:"max_backups" mapstructure:"max_backups"` // 保留的旧日志文件数量
MaxAge int `json:"max_age" mapstructure:"max_age"` // 日志文件保留天数
}
// SecurityConfig 安全配置结构体
// 包含应用程序安全相关的配置信息
type SecurityConfig struct {
JWTSecret string `json:"jwt_secret" mapstructure:"jwt_secret"` // JWT签名密钥
EncryptionKey string `json:"encryption_key" mapstructure:"encryption_key"` // 数据加密密钥
JWTRefreshThresholdHours int `json:"jwt_refresh_threshold_hours" mapstructure:"jwt_refresh_threshold_hours"` // JWT令牌刷新阈值小时
}
// AppConfig 应用配置结构体
type AppConfig struct {
Server ServerConfig `json:"server" mapstructure:"server"`
Database DatabaseConfig `json:"database" mapstructure:"database"`
Redis RedisConfig `json:"redis" mapstructure:"redis"`
Log LogConfig `json:"log" mapstructure:"log"`
Security SecurityConfig `json:"security" mapstructure:"security"`
}
// ValidateAndSetDefaults 验证配置并设置默认值
func ValidateAndSetDefaults() (*AppConfig, error) {
var config AppConfig
// 解析配置到结构体
if err := viper.Unmarshal(&config); err != nil {
return nil, fmt.Errorf("解析配置失败: %w", err)
}
// 设置默认值
setDefaults(&config)
// 验证配置
if err := validateConfig(&config); err != nil {
return nil, fmt.Errorf("配置验证失败: %w", err)
}
log.Info("配置验证通过")
return &config, nil
}
// setDefaults 设置默认值
func setDefaults(config *AppConfig) {
// 服务器默认值
if config.Server.Host == "" {
config.Server.Host = "0.0.0.0"
}
if config.Server.Port == 0 {
config.Server.Port = 8080
}
if config.Server.Mode == "" {
config.Server.Mode = "debug"
}
// 数据库默认值
if config.Database.Type == "" {
config.Database.Type = "sqlite"
}
if config.Database.MySQL.Port == 0 {
config.Database.MySQL.Port = 3306
}
if config.Database.MySQL.Charset == "" {
config.Database.MySQL.Charset = "utf8mb4"
}
if config.Database.MySQL.MaxIdleConns == 0 {
config.Database.MySQL.MaxIdleConns = 10
}
if config.Database.MySQL.MaxOpenConns == 0 {
config.Database.MySQL.MaxOpenConns = 100
}
if config.Database.SQLite.Path == "" {
config.Database.SQLite.Path = "./recharge.db"
}
// Redis默认值
if config.Redis.Host == "" {
config.Redis.Host = "localhost"
}
if config.Redis.Port == 0 {
config.Redis.Port = 6379
}
// 日志默认值
if config.Log.Level == "" {
config.Log.Level = "info"
}
// 不为空的日志文件路径设置默认值,保持为空表示只输出到控制台
if config.Log.MaxSize == 0 {
config.Log.MaxSize = 100
}
if config.Log.MaxBackups == 0 {
config.Log.MaxBackups = 5
}
if config.Log.MaxAge == 0 {
config.Log.MaxAge = 30
}
// 安全配置默认值
if config.Security.JWTSecret == "" || config.Security.JWTSecret == "your-jwt-secret-key" {
config.Security.JWTSecret = "default-jwt-secret-change-in-production"
}
if config.Security.EncryptionKey == "" || config.Security.EncryptionKey == "your-encryption-key" {
config.Security.EncryptionKey = "default-encryption-key-change-in-production"
}
if config.Security.JWTRefreshThresholdHours == 0 {
config.Security.JWTRefreshThresholdHours = 6
}
}
// validateConfig 验证配置
func validateConfig(config *AppConfig) error {
// 验证服务器配置
if err := validateServerConfig(&config.Server); err != nil {
return fmt.Errorf("服务器配置错误: %w", err)
}
// 验证数据库配置
if err := validateDatabaseConfig(&config.Database); err != nil {
return fmt.Errorf("数据库配置错误: %w", err)
}
// 验证Redis配置
if err := validateRedisConfig(&config.Redis); err != nil {
return fmt.Errorf("redis配置错误: %w", err)
}
// 验证日志配置
if err := validateLogConfig(&config.Log); err != nil {
return fmt.Errorf("日志配置错误: %w", err)
}
// 验证安全配置
if err := validateSecurityConfig(&config.Security); err != nil {
return fmt.Errorf("安全配置错误: %w", err)
}
return nil
}
// validateServerConfig 验证服务器配置
func validateServerConfig(config *ServerConfig) error {
// 验证主机地址
if config.Host != "" {
if ip := net.ParseIP(config.Host); ip == nil && config.Host != "localhost" {
return fmt.Errorf("无效的主机地址: %s", config.Host)
}
}
// 验证端口
if config.Port < 1 || config.Port > 65535 {
return fmt.Errorf("无效的端口号: %d端口号必须在1-65535之间", config.Port)
}
// 验证运行模式
validModes := []string{"debug", "release", "test"}
if !contains(validModes, config.Mode) {
return fmt.Errorf("无效的运行模式: %s支持的模式: %s", config.Mode, strings.Join(validModes, ", "))
}
return nil
}
// validateDatabaseConfig 验证数据库配置
func validateDatabaseConfig(config *DatabaseConfig) error {
// 验证数据库类型
validTypes := []string{"mysql", "sqlite"}
if !contains(validTypes, config.Type) {
return fmt.Errorf("不支持的数据库类型: %s支持的类型: %s", config.Type, strings.Join(validTypes, ", "))
}
// 根据类型验证具体配置
switch config.Type {
case "mysql":
return validateMySQLConfig(&config.MySQL)
case "sqlite":
return validateSQLiteConfig(&config.SQLite)
}
return nil
}
// validateMySQLConfig 验证MySQL配置
func validateMySQLConfig(config *MySQLConfig) error {
if config.Host == "" {
return errors.New("MySQL主机地址不能为空")
}
if config.Port < 1 || config.Port > 65535 {
return fmt.Errorf("无效的MySQL端口号: %d", config.Port)
}
if config.Username == "" {
return errors.New("MySQL用户名不能为空")
}
if config.Database == "" {
return errors.New("MySQL数据库名不能为空")
}
if config.MaxIdleConns < 0 {
return errors.New("MySQL最大空闲连接数不能为负数")
}
if config.MaxOpenConns < 0 {
return errors.New("MySQL最大打开连接数不能为负数")
}
return nil
}
// validateSQLiteConfig 验证SQLite配置
func validateSQLiteConfig(config *SQLiteConfig) error {
if config.Path == "" {
return errors.New("SQLite数据库路径不能为空")
}
// 检查目录是否存在,不存在则创建
dir := filepath.Dir(config.Path)
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("创建SQLite数据库目录失败: %w", err)
}
}
return nil
}
// validateRedisConfig 验证Redis配置
func validateRedisConfig(config *RedisConfig) error {
if config.Host == "" {
return errors.New("Redis主机地址不能为空")
}
if config.Port < 1 || config.Port > 65535 {
return fmt.Errorf("无效的Redis端口号: %d", config.Port)
}
if config.DB < 0 || config.DB > 15 {
return fmt.Errorf("无效的Redis数据库索引: %d必须在0-15之间", config.DB)
}
return nil
}
// validateLogConfig 验证日志配置
func validateLogConfig(config *LogConfig) error {
// 验证日志级别
validLevels := []string{"trace", "debug", "info", "warn", "error", "fatal", "panic"}
if !contains(validLevels, config.Level) {
return fmt.Errorf("无效的日志级别: %s支持的级别: %s", config.Level, strings.Join(validLevels, ", "))
}
// 检查日志文件目录(仅当日志文件路径不为空时)
if config.File != "" {
dir := filepath.Dir(config.File)
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("创建日志目录失败: %w", err)
}
}
}
// 当日志文件路径为空时,不进行目录检查和创建
// 验证日志轮转配置
if config.MaxSize <= 0 {
return errors.New("日志文件最大大小必须大于0")
}
if config.MaxBackups < 0 {
return errors.New("日志备份文件数量不能为负数")
}
if config.MaxAge < 0 {
return errors.New("日志文件保留天数不能为负数")
}
return nil
}
// validateSecurityConfig 验证安全配置
func validateSecurityConfig(config *SecurityConfig) error {
if len(config.JWTSecret) < 16 {
return errors.New("JWT密钥长度不能少于16个字符")
}
if len(config.EncryptionKey) < 16 {
return errors.New("加密密钥长度不能少于16个字符")
}
if config.JWTRefreshThresholdHours < 1 || config.JWTRefreshThresholdHours > 23 {
return errors.New("JWT令牌刷新阈值必须在1-23小时之间")
}
// 检查是否使用默认值(生产环境警告)
if strings.Contains(config.JWTSecret, "default") {
log.Warn("检测到使用默认JWT密钥生产环境请更换为安全的密钥")
}
if strings.Contains(config.EncryptionKey, "default") {
log.Warn("检测到使用默认加密密钥,生产环境请更换为安全的密钥")
}
return nil
}
// contains 检查切片是否包含指定元素
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// GetConfigValue 获取配置值,支持类型转换和默认值
func GetConfigValue[T any](key string, defaultValue T) T {
if !viper.IsSet(key) {
return defaultValue
}
value := viper.Get(key)
if result, ok := value.(T); ok {
return result
}
// 尝试类型转换
if converted, err := convertValue[T](value); err == nil {
return converted
}
return defaultValue
}
// convertValue 尝试类型转换
func convertValue[T any](value interface{}) (T, error) {
var zero T
str := fmt.Sprintf("%v", value)
switch any(zero).(type) {
case int:
if i, err := strconv.Atoi(str); err == nil {
return any(i).(T), nil
}
case string:
return any(str).(T), nil
case bool:
if b, err := strconv.ParseBool(str); err == nil {
return any(b).(T), nil
}
}
return zero, fmt.Errorf("无法转换类型")
}

41
constants/status.go Normal file
View File

@@ -0,0 +1,41 @@
package constants
// 卡密状态常量
// CardStatus 定义卡密的状态
const (
// CardStatusUnused 未使用
CardStatusUnused = 0
// CardStatusUsed 已使用
CardStatusUsed = 1
// CardStatusDisabled 禁用
CardStatusDisabled = 2
)
// 登录类型状态常量
// LoginTypeStatus 定义登录类型的状态
const (
// LoginTypeStatusDisabled 禁用
LoginTypeStatusDisabled = 0
// LoginTypeStatusEnabled 启用
LoginTypeStatusEnabled = 1
)
// 卡密类型状态常量
// CardTypeStatus 定义卡密类型的状态
const (
// CardTypeStatusDisabled 禁用
CardTypeStatusDisabled = 0
// CardTypeStatusEnabled 启用
CardTypeStatusEnabled = 1
)
// 验证码类型常量
// VerificationCodeType 定义验证码的类型
const (
// VerificationCodeTypeText 文本验证码
VerificationCodeTypeText = 1
// VerificationCodeTypeImage 图片验证码
VerificationCodeTypeImage = 2
)

388
controllers/admin/app.go Normal file
View File

@@ -0,0 +1,388 @@
package admin
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"networkDev/database"
"networkDev/models"
"networkDev/utils"
"strconv"
"strings"
"github.com/google/uuid"
"github.com/sirupsen/logrus"
)
// AppsFragmentHandler 应用列表页面片段处理器
func AppsFragmentHandler(w http.ResponseWriter, r *http.Request) {
utils.RenderTemplate(w, "apps.html", map[string]interface{}{
"Title": "应用管理",
})
}
// AppsListHandler 应用列表API处理器
func AppsListHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 获取分页参数
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page <= 0 {
page = 1
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 10
}
// 获取搜索参数
search := strings.TrimSpace(r.URL.Query().Get("search"))
// 构建查询
db, err := database.GetDB()
if err != nil {
logrus.WithError(err).Error("Failed to get database connection")
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var apps []models.App
var total int64
query := db.Model(&models.App{})
// 如果有搜索条件
if search != "" {
query = query.Where("name LIKE ? OR uuid LIKE ?", "%"+search+"%", "%"+search+"%")
}
// 获取总数
if err := query.Count(&total).Error; err != nil {
logrus.WithError(err).Error("Failed to count apps")
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// 分页查询
offset := (page - 1) * limit
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&apps).Error; err != nil {
logrus.WithError(err).Error("Failed to query apps")
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// 返回结果
response := map[string]interface{}{
"code": 0,
"msg": "success",
"count": total,
"data": apps,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// AppCreateHandler 创建应用API处理器
func AppCreateHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Name string `json:"name"`
Version string `json:"version"`
Status int `json:"status"`
DownloadType int `json:"download_type"`
ForceUpdate int `json:"force_update"`
DownloadURL string `json:"download_url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
logrus.WithError(err).Error("Failed to decode JSON request")
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// 验证必填字段
if strings.TrimSpace(req.Name) == "" {
logrus.Error("App name is empty")
http.Error(w, "应用名称不能为空", http.StatusBadRequest)
return
}
// 设置默认值
if req.Version == "" {
req.Version = "1.0.0"
}
logrus.WithFields(logrus.Fields{
"name": req.Name,
"version": req.Version,
"status": req.Status,
"download_type": req.DownloadType,
"download_url": req.DownloadURL,
"force_update": req.ForceUpdate,
}).Info("Received app create request")
// 创建应用
app := models.App{
Name: strings.TrimSpace(req.Name),
Version: req.Version,
Status: req.Status,
DownloadType: req.DownloadType,
DownloadURL: strings.TrimSpace(req.DownloadURL),
ForceUpdate: req.ForceUpdate,
}
// 确保UUID和Secret被设置虽然BeforeCreate钩子应该处理这些但为了保险起见
if app.UUID == "" {
app.UUID = uuid.New().String()
}
if app.Secret == "" {
// 生成32位大写16进制随机字符
bytes := make([]byte, 16) // 16字节 = 32位16进制字符
rand.Read(bytes)
app.Secret = strings.ToUpper(hex.EncodeToString(bytes))
}
db, err := database.GetDB()
if err != nil {
logrus.WithError(err).Error("Failed to get database connection")
http.Error(w, "数据库连接失败", http.StatusInternalServerError)
return
}
if err := db.Create(&app).Error; err != nil {
logrus.WithError(err).Error("Failed to create app")
http.Error(w, "创建应用失败", http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"code": 0,
"msg": "创建成功",
"data": app,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// AppUpdateHandler 更新应用API处理器
func AppUpdateHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
ID uint `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Status int `json:"status"`
DownloadType int `json:"download_type"`
DownloadURL string `json:"download_url"`
ForceUpdate int `json:"force_update"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// 验证必填字段
if req.ID == 0 {
http.Error(w, "应用ID不能为空", http.StatusBadRequest)
return
}
if strings.TrimSpace(req.Name) == "" {
http.Error(w, "应用名称不能为空", http.StatusBadRequest)
return
}
db, err := database.GetDB()
if err != nil {
logrus.WithError(err).Error("Failed to get database connection")
http.Error(w, "数据库连接失败", http.StatusInternalServerError)
return
}
// 查找应用
var app models.App
if err := db.First(&app, req.ID).Error; err != nil {
http.Error(w, "应用不存在", http.StatusNotFound)
return
}
// 更新字段
app.Name = strings.TrimSpace(req.Name)
app.Version = req.Version
app.Status = req.Status
app.DownloadType = req.DownloadType
app.DownloadURL = strings.TrimSpace(req.DownloadURL)
app.ForceUpdate = req.ForceUpdate
if err := db.Save(&app).Error; err != nil {
logrus.WithError(err).Error("Failed to update app")
http.Error(w, "更新应用失败", http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"code": 0,
"msg": "更新成功",
"data": app,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// AppDeleteHandler 删除应用API处理器
func AppDeleteHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
ID uint `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.ID == 0 {
http.Error(w, "应用ID不能为空", http.StatusBadRequest)
return
}
db, err := database.GetDB()
if err != nil {
logrus.WithError(err).Error("Failed to get database connection")
http.Error(w, "数据库连接失败", http.StatusInternalServerError)
return
}
// 删除应用
if err := db.Delete(&models.App{}, req.ID).Error; err != nil {
logrus.WithError(err).Error("Failed to delete app")
http.Error(w, "删除应用失败", http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"code": 0,
"msg": "删除成功",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// AppsBatchDeleteHandler 批量删除应用API处理器
func AppsBatchDeleteHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
IDs []uint `json:"ids"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if len(req.IDs) == 0 {
http.Error(w, "请选择要删除的应用", http.StatusBadRequest)
return
}
db, err := database.GetDB()
if err != nil {
logrus.WithError(err).Error("Failed to get database connection")
http.Error(w, "数据库连接失败", http.StatusInternalServerError)
return
}
// 批量删除
if err := db.Delete(&models.App{}, req.IDs).Error; err != nil {
logrus.WithError(err).Error("Failed to batch delete apps")
http.Error(w, "批量删除失败", http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"code": 0,
"msg": "批量删除成功",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// AppsBatchUpdateStatusHandler 批量更新应用状态API处理器
func AppsBatchUpdateStatusHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
IDs []uint `json:"ids"`
Status int `json:"status"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if len(req.IDs) == 0 {
http.Error(w, "请选择要更新的应用", http.StatusBadRequest)
return
}
if req.Status != 0 && req.Status != 1 {
http.Error(w, "状态值无效", http.StatusBadRequest)
return
}
db, err := database.GetDB()
if err != nil {
logrus.WithError(err).Error("Failed to get database connection")
http.Error(w, "数据库连接失败", http.StatusInternalServerError)
return
}
// 批量更新状态
if err := db.Model(&models.App{}).Where("id IN ?", req.IDs).Update("status", req.Status).Error; err != nil {
logrus.WithError(err).Error("Failed to batch update app status")
http.Error(w, "批量更新状态失败", http.StatusInternalServerError)
return
}
statusText := "禁用"
if req.Status == 1 {
statusText = "启用"
}
response := map[string]interface{}{
"code": 0,
"msg": "批量" + statusText + "成功",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

314
controllers/admin/auth.go Normal file
View File

@@ -0,0 +1,314 @@
package admin
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"networkDev/database"
"networkDev/models"
"networkDev/utils"
"github.com/golang-jwt/jwt/v5"
"github.com/spf13/viper"
)
// LoginPageHandler 管理员登录页渲染处理器
// - 如果已登录则重定向到 /admin
// - 否则渲染 web/template/admin/login.html 模板
func LoginPageHandler(w http.ResponseWriter, r *http.Request) {
// 已登录直接跳转到后台布局
if IsAdminAuthenticated(r) {
http.Redirect(w, r, "/admin", http.StatusFound)
return
}
data := utils.GetDefaultTemplateData()
data["Title"] = "管理员登录"
utils.RenderTemplate(w, "login.html", data)
}
// LoginHandler 管理员登录接口
// - 接收JSON: {username, password}
// - 验证用户存在与密码正确性
// - 仅允许 Role=0 的管理员登录
// - 成功后设置简单的会话Cookie后续可切换为JWT或更完善的Session
func LoginHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
utils.JsonResponse(w, http.StatusBadRequest, false, "请求参数错误", nil)
return
}
if body.Username == "" || body.Password == "" {
utils.JsonResponse(w, http.StatusBadRequest, false, "用户名和密码不能为空", nil)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
var user models.User
dbErr := db.Where("username = ?", body.Username).First(&user).Error
if dbErr != nil {
utils.JsonResponse(w, http.StatusUnauthorized, false, "用户不存在或密码错误", nil)
return
}
if user.Role != 0 {
utils.JsonResponse(w, http.StatusForbidden, false, "非管理员账号不可登录后台", nil)
return
}
// 使用盐值验证密码
if !utils.VerifyPasswordWithSalt(body.Password, user.PasswordSalt, user.Password) {
utils.JsonResponse(w, http.StatusUnauthorized, false, "用户不存在或密码错误", nil)
return
}
// 生成JWT令牌
token, err := generateJWTToken(user)
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "生成令牌失败", nil)
return
}
// 设置JWT CookieHttpOnly安全
cookie := &http.Cookie{
Name: "admin_session",
Value: token,
Path: "/",
HttpOnly: true,
Secure: false, // 生产环境应设置为trueHTTPS
MaxAge: 24 * 60 * 60, // 24小时
}
http.SetCookie(w, cookie)
utils.JsonResponse(w, http.StatusOK, true, "登录成功", map[string]interface{}{
"redirect": "/admin",
})
}
// LogoutHandler 管理员登出
// - 清理JWT Cookie会话
// - 确保令牌完全失效
func LogoutHandler(w http.ResponseWriter, r *http.Request) {
// 清理JWT Cookie
cookie := &http.Cookie{
Name: "admin_session",
Value: "",
Path: "/",
HttpOnly: true,
Secure: false, // 生产环境应设置为true
MaxAge: -1, // 立即失效
Expires: time.Unix(0, 0), // 确保过期
}
http.SetCookie(w, cookie)
// 可选将JWT令牌加入黑名单需要Redis或数据库支持
// 这里可以实现JWT黑名单机制
utils.JsonResponse(w, http.StatusOK, true, "已退出登录", map[string]interface{}{
"redirect": "/admin/login",
})
}
// JWT密钥生产环境应从配置文件或环境变量读取
var jwtSecret = []byte(viper.GetString("security.jwt_secret"))
// JWTClaims JWT载荷结构
type JWTClaims struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
Role int `json:"role"`
jwt.RegisteredClaims
}
// generateJWTToken 生成JWT令牌
// - 包含用户ID、用户名、角色信息
// - 设置24小时过期时间
// - 使用HMAC-SHA256签名
func generateJWTToken(user models.User) (string, error) {
claims := JWTClaims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "凌动技术",
Subject: strconv.Itoa(int(user.ID)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
}
// parseJWTToken 解析并验证JWT令牌
// - 验证签名有效性
// - 检查过期时间
// - 返回用户信息
func parseJWTToken(tokenString string) (*JWTClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return jwtSecret, nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid {
return claims, nil
}
return nil, fmt.Errorf("invalid token")
}
// IsAdminAuthenticated 判断管理员是否已认证(导出)
// - 检查admin_session Cookie中的JWT令牌
// - 验证令牌签名、过期时间和用户角色
func IsAdminAuthenticated(r *http.Request) bool {
cookie, err := r.Cookie("admin_session")
if err != nil || cookie.Value == "" {
return false
}
// 解析并验证JWT令牌
claims, err := parseJWTToken(cookie.Value)
if err != nil {
return false
}
// 验证用户角色(只允许管理员角色=0
if claims.Role != 0 {
return false
}
// 可选:进一步验证用户是否仍然存在且有效
// 这里可以添加数据库查询来验证用户状态
return true
}
// GetCurrentAdminUser 获取当前登录的管理员用户信息
// - 从JWT令牌中提取用户信息
// - 自动刷新接近过期的令牌剩余时间少于6小时时刷新
// - 返回用户ID、用户名和角色
func GetCurrentAdminUser(r *http.Request) (*JWTClaims, error) {
cookie, err := r.Cookie("admin_session")
if err != nil {
return nil, fmt.Errorf("未找到会话信息")
}
claims, err := parseJWTToken(cookie.Value)
if err != nil {
return nil, fmt.Errorf("无效的会话信息")
}
if claims.Role != 0 {
return nil, fmt.Errorf("权限不足")
}
return claims, nil
}
// GetCurrentAdminUserWithRefresh 获取当前登录的管理员用户信息并自动刷新令牌
// - 从JWT令牌中提取用户信息
// - 自动刷新接近过期的令牌剩余时间少于6小时时刷新
// - 返回用户ID、用户名、角色和是否刷新了令牌
func GetCurrentAdminUserWithRefresh(w http.ResponseWriter, r *http.Request) (*JWTClaims, bool, error) {
cookie, err := r.Cookie("admin_session")
if err != nil {
return nil, false, fmt.Errorf("未找到会话信息")
}
claims, err := parseJWTToken(cookie.Value)
if err != nil {
return nil, false, fmt.Errorf("无效的会话信息")
}
if claims.Role != 0 {
return nil, false, fmt.Errorf("权限不足")
}
// 检查是否需要刷新令牌(根据配置的阈值)
refreshed := false
refreshThreshold := time.Duration(viper.GetInt("security.jwt_refresh_threshold_hours")) * time.Hour
if time.Until(claims.ExpiresAt.Time) < refreshThreshold {
// 生成新的JWT令牌
user := models.User{
ID: claims.UserID,
Username: claims.Username,
Role: claims.Role,
}
newToken, err := generateJWTToken(user)
if err == nil {
// 更新Cookie
newCookie := &http.Cookie{
Name: "admin_session",
Value: newToken,
Path: "/",
HttpOnly: true,
Secure: false, // 生产环境应设置为trueHTTPS
MaxAge: 24 * 60 * 60, // 24小时
}
http.SetCookie(w, newCookie)
refreshed = true
// 更新claims的过期时间
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(24 * time.Hour))
claims.IssuedAt = jwt.NewNumericDate(time.Now())
}
}
return claims, refreshed, nil
}
// AdminAuthRequired 管理员认证拦截中间件
// - 未登录:重定向到 /admin/login
// - 已登录:自动刷新接近过期的令牌,然后放行到后续处理器
func AdminAuthRequired(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 尝试获取用户信息并自动刷新令牌
claims, refreshed, err := GetCurrentAdminUserWithRefresh(w, r)
if err != nil {
// 中文注释区分普通页面请求与AJAX/JSON请求
// - 对 AJAX/JSON直接返回 401 JSON便于前端处理如提示重新登录
// - 对普通页面:保持原有重定向到登录页
accept := r.Header.Get("Accept")
xrw := strings.ToLower(strings.TrimSpace(r.Header.Get("X-Requested-With")))
if strings.Contains(accept, "application/json") || xrw == "xmlhttprequest" {
utils.JsonResponse(w, http.StatusUnauthorized, false, "未登录或会话已过期", nil)
return
}
http.Redirect(w, r, "/admin/login", http.StatusFound)
return
}
// 如果令牌被刷新,可以在这里记录日志(可选)
if refreshed {
// 可以添加日志记录令牌刷新事件
_ = claims // 避免未使用变量警告
}
next(w, r)
}
}

650
controllers/admin/card.go Normal file
View File

@@ -0,0 +1,650 @@
package admin
import (
"crypto/rand"
// 移除 CSV 导出,改为自定义分隔符文本导出
// "encoding/csv"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"networkDev/database"
"networkDev/models"
"networkDev/utils"
"strconv"
"strings"
"time"
"github.com/sirupsen/logrus"
)
// 生成指定长度的十六进制随机字符串
// 入参 n 表示需要的随机字符数(非字节数);返回小写十六进制字符串
func genRandomHex(n int) string {
if n <= 0 {
return ""
}
// 由于 hex 每个字节会转成 2 个字符,因此需要 (n+1)/2 个字节
byteLen := (n + 1) / 2
b := make([]byte, byteLen)
if _, err := rand.Read(b); err != nil {
return ""
}
s := hex.EncodeToString(b)
if len(s) > n {
s = s[:n]
}
return s
}
// 根据前缀和总长度构建卡号
// - totalLen <= 0 时按 18 处理
// - 若前缀长度 >= totalLen则自动扩展为 前缀长度+18
// - uppercase=true 表示最终结果转为大写false 表示小写
func buildCardNumber(prefix string, totalLen int, uppercase bool) string {
if totalLen <= 0 {
totalLen = 18
}
if len(prefix) >= totalLen {
totalLen = len(prefix) + 18
}
rest := totalLen - len(prefix)
s := prefix + genRandomHex(rest)
if uppercase {
return strings.ToUpper(s)
}
return strings.ToLower(s)
}
// CardsFragmentHandler 卡密管理片段渲染
// - 渲染 cards.html 列表与表单界面
func CardsFragmentHandler(w http.ResponseWriter, r *http.Request) {
utils.RenderTemplate(w, "cards.html", map[string]interface{}{})
}
// CardsListHandler 获取卡密列表
// - 支持GET
// - 支持分页查询参数page、page_size
// - 支持筛选参数card_type_id、status、batch、keyword(卡号/备注/批次模糊匹配)
// - 返回卡密列表和分页信息
func CardsListHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
// 获取查询参数
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
pageSize, _ := strconv.Atoi(r.URL.Query().Get("page_size"))
cardTypeIDStr := r.URL.Query().Get("card_type_id")
statusStr := r.URL.Query().Get("status")
batch := r.URL.Query().Get("batch")
// 中文注释keyword 支持在 card_number、remark、batch 三个字段上进行模糊匹配
keyword := strings.TrimSpace(r.URL.Query().Get("keyword"))
// 设置默认分页参数
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 100 {
pageSize = 20
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
// 构建查询条件(去除无效的 Preload前端已通过 card_type_id 自行映射类型名称)
query := db.Model(&models.Card{})
// 筛选条件
if cardTypeIDStr != "" {
if cardTypeID, err := strconv.Atoi(cardTypeIDStr); err == nil && cardTypeID > 0 {
query = query.Where("card_type_id = ?", cardTypeID)
}
}
if statusStr != "" {
if status, err := strconv.Atoi(statusStr); err == nil {
query = query.Where("status = ?", status)
}
}
if batch != "" {
query = query.Where("batch LIKE ?", "%"+batch+"%")
}
// 中文注释:当提供 keyword 时,在卡号、备注、批次三个字段上进行 OR 模糊匹配
if keyword != "" {
kw := "%" + keyword + "%"
query = query.Where("(card_number LIKE ? OR remark LIKE ? OR batch LIKE ?)", kw, kw, kw)
}
// 计算总数
var total int64
if err := query.Count(&total).Error; err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "统计总数失败", nil)
return
}
// 分页查询
var cards []models.Card
offset := (page - 1) * pageSize
if err := query.Order("id desc").Offset(offset).Limit(pageSize).Find(&cards).Error; err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "查询失败", nil)
return
}
// 中文注释:为每条卡记录补充类型名称,避免前端依赖异步类型映射导致显示“未知类型”
// 1) 先查询类型列表并构建 id->name 的映射表
var cardTypeList []models.CardType
_ = db.Model(&models.CardType{}).Find(&cardTypeList).Error
typeNameMap := make(map[uint]string, len(cardTypeList))
for _, t := range cardTypeList {
typeNameMap[t.ID] = t.Name
}
// 2) 将卡列表转换为通用 map 列表,并附加 card_type_name 字段
items := make([]map[string]interface{}, 0, len(cards))
for _, c := range cards {
items = append(items, map[string]interface{}{
"id": c.ID,
"card_number": c.CardNumber,
"card_type_id": c.CardTypeID,
"card_type_name": typeNameMap[c.CardTypeID],
"status": c.Status,
"batch": c.Batch,
"remark": c.Remark,
"used_at": c.UsedAt,
"created_at": c.CreatedAt,
})
}
// 返回分页数据
result := map[string]interface{}{
"items": items,
"total": total,
"page": page,
"page_size": pageSize,
"pages": (total + int64(pageSize) - 1) / int64(pageSize),
}
utils.JsonResponse(w, http.StatusOK, true, "ok", result)
}
// CardCreateHandler 新增卡密
// - 接收JSON: {card_type_id, status, remark, prefix, length, uppercase, count}
// - card_number 与 batch 不再由前端传入,后端将自动生成:
// 1. 卡号:按 prefix 与 length 生成随机十六进制字符串支持大小写控制uppercase默认小写
// 2. 批次:基于设置表 card_batch_counter 自增,格式为 YYYYMMDD-000001
// 3. 生成数量:通过 count 控制一次生成的数量默认1最大500
func CardCreateHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
type reqBody struct {
CardTypeID uint `json:"card_type_id"`
Status int `json:"status"`
Remark string `json:"remark"`
Prefix string `json:"prefix"`
Length int `json:"length"`
Uppercase bool `json:"uppercase"`
Count int `json:"count"`
}
var body reqBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
utils.JsonResponse(w, http.StatusBadRequest, false, "请求体错误", nil)
return
}
if body.CardTypeID == 0 {
utils.JsonResponse(w, http.StatusBadRequest, false, "卡密类型ID不能为空", nil)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
// 检查卡密类型是否存在且启用
var cardType models.CardType
if err := db.First(&cardType, body.CardTypeID).Error; err != nil {
utils.JsonResponse(w, http.StatusBadRequest, false, "卡密类型不存在", nil)
return
}
// 检查卡密类型是否被禁用
if cardType.Status != 1 {
utils.JsonResponse(w, http.StatusBadRequest, false, "卡密类型已被禁用,无法创建卡密", nil)
return
}
// 规范化长度与大小写、生成数量参数
if body.Length <= 0 {
body.Length = 18
}
if body.Count <= 0 {
body.Count = 1
}
if body.Count > 500 {
body.Count = 500
}
// 生成批次(基于设置表 card_batch_counter 自增)
// 格式YYYYMMDD-000001每天不重置仅简单自增计数
var batch string
var setting models.Settings
if err := db.Where("name = ?", "card_batch_counter").First(&setting).Error; err != nil {
// 若不存在该设置项,则创建并从 1 开始
setting = models.Settings{Name: "card_batch_counter", Value: "1", Description: "卡密批次号计数器(用于记录上次生成批次号的序号,自增使用)"}
if e := db.Create(&setting).Error; e != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "初始化批次计数器失败", nil)
return
}
batch = time.Now().Format("20060102") + "-" + fmt.Sprintf("%06d", 1)
} else {
cnt, _ := strconv.Atoi(setting.Value)
cnt++
newVal := strconv.Itoa(cnt)
if e := db.Model(&models.Settings{}).Where("id = ?", setting.ID).Update("value", newVal).Error; e != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "更新批次计数器失败", nil)
return
}
batch = time.Now().Format("20060102") + "-" + fmt.Sprintf("%06d", cnt)
}
// 中文注释计算合法状态值1=已使用2=禁用其它按未使用0处理
safeStatus := body.Status
if safeStatus != 1 && safeStatus != 2 {
safeStatus = 0
}
// 中文注释:循环生成 count 条卡密若单条创建失败则重试最多5次
success := 0
for i := 0; i < body.Count; i++ {
card := models.Card{
CardNumber: buildCardNumber(body.Prefix, body.Length, body.Uppercase),
CardTypeID: body.CardTypeID,
Status: safeStatus,
Batch: batch,
Remark: body.Remark,
}
var createErr error
for j := 0; j < 5; j++ {
createErr = db.Create(&card).Error
if createErr == nil {
success++
break
}
// 失败则重新生成一次卡号后重试
card.CardNumber = buildCardNumber(body.Prefix, body.Length, body.Uppercase)
}
}
if success == 0 {
utils.JsonResponse(w, http.StatusBadRequest, false, "创建失败,可能是卡密号码重复", nil)
return
}
result := map[string]interface{}{
"created": success,
"batch": batch,
}
utils.JsonResponse(w, http.StatusOK, true, fmt.Sprintf("创建成功:%d条", success), result)
}
// CardUpdateHandler 更新卡密
// - 接收JSON: {id, card_number(可选), card_type_id(可选), status, batch(可选), remark}
// - 说明card_number 与 batch 若未提供或为空,则不会更新对应字段
func CardUpdateHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
type reqBody struct {
ID uint `json:"id"`
CardNumber string `json:"card_number"`
CardTypeID uint `json:"card_type_id"`
Status int `json:"status"`
Batch string `json:"batch"`
Remark string `json:"remark"`
}
var body reqBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
utils.JsonResponse(w, http.StatusBadRequest, false, "请求体错误", nil)
return
}
if body.ID == 0 {
utils.JsonResponse(w, http.StatusBadRequest, false, "缺少ID", nil)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
// 检查卡密类型是否存在且启用如果提供了新的卡密类型ID
if body.CardTypeID > 0 {
var cardType models.CardType
if err := db.First(&cardType, body.CardTypeID).Error; err != nil {
utils.JsonResponse(w, http.StatusBadRequest, false, "卡密类型不存在", nil)
return
}
// 检查卡密类型是否被禁用
if cardType.Status != 1 {
utils.JsonResponse(w, http.StatusBadRequest, false, "卡密类型已被禁用,无法更新为此类型", nil)
return
}
}
// 中文注释:若尝试将状态置为未使用(0),则直接允许
if body.Status == 0 {
var existing models.Card
if err := db.First(&existing, body.ID).Error; err != nil {
utils.JsonResponse(w, http.StatusBadRequest, false, "卡密不存在", nil)
return
}
}
// 构建更新字段
updates := map[string]interface{}{}
if body.CardNumber != "" {
updates["card_number"] = body.CardNumber
}
if body.CardTypeID > 0 {
updates["card_type_id"] = body.CardTypeID
}
updates["status"] = body.Status
// 仅当提供非空 batch 时才更新,防止被清空
if strings.TrimSpace(body.Batch) != "" {
updates["batch"] = body.Batch
}
updates["remark"] = body.Remark
if err := db.Model(&models.Card{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil {
utils.JsonResponse(w, http.StatusBadRequest, false, "更新失败,可能是卡密号码重复", nil)
return
}
utils.JsonResponse(w, http.StatusOK, true, "更新成功", nil)
}
// CardDeleteHandler 删除单个卡密
// - 接收JSON: {id}
func CardDeleteHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
ID uint `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ID == 0 {
utils.JsonResponse(w, http.StatusBadRequest, false, "参数错误", nil)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
if err := db.Delete(&models.Card{}, body.ID).Error; err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "删除失败", nil)
return
}
utils.JsonResponse(w, http.StatusOK, true, "删除成功", nil)
}
// CardsBatchDeleteHandler 批量删除卡密
// - 接收JSON: {ids: []}
func CardsBatchDeleteHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
IDs []uint `json:"ids"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.IDs) == 0 {
utils.JsonResponse(w, http.StatusBadRequest, false, "参数错误", nil)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
if err := db.Delete(&models.Card{}, body.IDs).Error; err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "批量删除失败", nil)
return
}
utils.JsonResponse(w, http.StatusOK, true, "批量删除成功", nil)
}
// CardsBatchUpdateStatusHandler 批量更新卡密状态
// - 接收JSON: {ids: [], status: int}
// - status: 0=未使用1=已使用2=禁用
func CardsBatchUpdateStatusHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
IDs []uint `json:"ids"`
Status int `json:"status"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.IDs) == 0 {
utils.JsonResponse(w, http.StatusBadRequest, false, "参数错误", nil)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
// 中文注释:允许批量重置为未使用(0)
if err := db.Model(&models.Card{}).Where("id IN ?", body.IDs).Update("status", body.Status).Error; err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "批量更新失败", nil)
return
}
utils.JsonResponse(w, http.StatusOK, true, "操作成功", nil)
}
// GetCardTypesHandler 获取卡密类型列表(供前端下拉选择)
// - 仅支持GET请求
// - 只返回启用状态的卡密类型,用于前端下拉选择
func GetCardTypesHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
var cardTypes []models.CardType
// 中文注释:根据可选参数 all 决定是否仅返回启用类型
// - 未提供或为其它值仅返回启用status=1
// - all=1/true/yes返回所有状态的类型用于筛选下拉场景
q := db.Model(&models.CardType{})
all := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("all")))
if !(all == "1" || all == "true" || all == "yes") {
q = q.Where("status = ?", 1)
}
if err := q.Order("id asc").Find(&cardTypes).Error; err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "查询失败", nil)
return
}
utils.JsonResponse(w, http.StatusOK, true, "ok", cardTypes)
}
// CardsExportHandler 导出卡密为文本文件
// - 支持GET
// - 筛选参数card_type_id、status、batch、remark
// - 导出字段(按顺序):卡号、状态、创建时间;使用“----”分隔
func CardsExportHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
// 解析筛选参数
cardTypeIDStr := strings.TrimSpace(r.URL.Query().Get("card_type_id"))
statusStr := strings.TrimSpace(r.URL.Query().Get("status"))
batch := strings.TrimSpace(r.URL.Query().Get("batch"))
remark := strings.TrimSpace(r.URL.Query().Get("remark"))
db, err := database.GetDB()
if err != nil {
http.Error(w, "数据库连接失败", http.StatusInternalServerError)
return
}
// 构建查询
query := db.Model(&models.Card{})
if cardTypeIDStr != "" {
if id, err := strconv.Atoi(cardTypeIDStr); err == nil && id > 0 {
query = query.Where("card_type_id = ?", id)
}
}
if statusStr != "" {
if s, err := strconv.Atoi(statusStr); err == nil {
query = query.Where("status = ?", s)
}
}
if batch != "" {
query = query.Where("batch LIKE ?", "%"+batch+"%")
}
if remark != "" {
query = query.Where("remark LIKE ?", "%"+remark+"%")
}
// 查询数据按ID倒序
var cards []models.Card
if err := query.Order("id desc").Find(&cards).Error; err != nil {
http.Error(w, "查询失败", http.StatusInternalServerError)
return
}
// 设置响应头(文本下载)
now := time.Now().Format("20060102150405")
filename := fmt.Sprintf("cards_%s.txt", now)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
// 写入UTF-8 BOM避免Excel/记事本中文乱码
_, _ = w.Write([]byte{0xEF, 0xBB, 0xBF})
// 写入表头
_, _ = w.Write([]byte("卡号----状态----创建时间\n"))
// 时间格式
const tf = "2006-01-02 15:04:05"
// 状态转文字
statusText := func(s int) string {
switch s {
case 0:
return "未使用"
case 1:
return "已使用"
default:
return "禁用"
}
}
// 写入数据行(以“----”分隔)
for _, c := range cards {
record := []string{
c.CardNumber,
statusText(c.Status),
c.CreatedAt.Format(tf),
}
line := strings.Join(record, "----") + "\n"
if _, err := w.Write([]byte(line)); err != nil {
continue
}
}
}
// CardsExportSelectedHandler 导出选中的卡密为文本文件
// - 支持GET
// - 参数ids逗号分隔的卡密ID列表
// - 导出字段(按顺序):卡号、状态、创建时间;使用"----"分隔
func CardsExportSelectedHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
// 解析选中的卡密ID列表
idsStr := strings.TrimSpace(r.URL.Query().Get("ids"))
if idsStr == "" {
http.Error(w, "请提供要导出的卡密ID列表", http.StatusBadRequest)
return
}
// 解析ID列表
idStrings := strings.Split(idsStr, ",")
var ids []uint
for _, idStr := range idStrings {
if id, err := strconv.Atoi(strings.TrimSpace(idStr)); err == nil && id > 0 {
ids = append(ids, uint(id))
}
}
if len(ids) == 0 {
http.Error(w, "无效的卡密ID列表", http.StatusBadRequest)
return
}
db, err := database.GetDB()
if err != nil {
http.Error(w, "数据库连接失败", http.StatusInternalServerError)
return
}
// 查询选中的卡密数据按ID倒序
var cards []models.Card
if err := db.Where("id IN ?", ids).Order("id desc").Find(&cards).Error; err != nil {
logrus.WithError(err).Error("查询选中卡密失败")
http.Error(w, "查询卡密数据失败", http.StatusInternalServerError)
return
}
if len(cards) == 0 {
http.Error(w, "未找到指定的卡密数据", http.StatusNotFound)
return
}
// 设置响应头,触发下载
filename := fmt.Sprintf("selected_cards_%s.txt", time.Now().Format("20060102_150405"))
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
// 写入数据
tf := "2006-01-02 15:04:05"
for _, c := range cards {
// 状态转换
var statusText string
switch c.Status {
case 0:
statusText = "未使用"
case 1:
statusText = "已使用"
default:
statusText = "禁用"
}
// 格式:卡号----状态----创建时间
record := []string{
c.CardNumber,
statusText,
c.CreatedAt.Format(tf),
}
line := strings.Join(record, "----") + "\n"
if _, err := w.Write([]byte(line)); err != nil {
continue
}
}
}

View File

@@ -0,0 +1,181 @@
package admin
import (
"net/http"
"networkDev/constants"
"networkDev/database"
"networkDev/models"
"networkDev/utils"
"time"
)
// CardStatsOverviewHandler 卡密统计概览API
// - 返回当日和所有卡密的统计信息
// - 包括:总数、使用/未使用/禁用状态分布
func CardStatsOverviewHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
// 获取当日统计
today := time.Now().Format("2006-01-02")
todayStart := today + " 00:00:00"
todayEnd := today + " 23:59:59"
// 当日卡密统计
var todayTotal int64
var todayByStatus = make(map[int]int64)
// 当日总数
db.Model(&models.Card{}).Where("created_at >= ? AND created_at <= ?", todayStart, todayEnd).Count(&todayTotal)
// 当日按状态分布
var todayStatusCounts []struct {
Status int `json:"status"`
Count int64 `json:"count"`
}
db.Model(&models.Card{}).
Select("status, count(*) as count").
Where("created_at >= ? AND created_at <= ?", todayStart, todayEnd).
Group("status").
Find(&todayStatusCounts)
for _, sc := range todayStatusCounts {
todayByStatus[sc.Status] = sc.Count
}
// 所有卡密统计
var allTotal int64
var allByStatus = make(map[int]int64)
// 总数
db.Model(&models.Card{}).Count(&allTotal)
// 按状态分布
var allStatusCounts []struct {
Status int `json:"status"`
Count int64 `json:"count"`
}
db.Model(&models.Card{}).
Select("status, count(*) as count").
Group("status").
Find(&allStatusCounts)
for _, sc := range allStatusCounts {
allByStatus[sc.Status] = sc.Count
}
// 构建响应数据
data := map[string]interface{}{
"today": map[string]interface{}{
"total": todayTotal,
"by_status": todayByStatus,
},
"all": map[string]interface{}{
"total": allTotal,
"by_status": allByStatus,
},
}
utils.JsonResponse(w, http.StatusOK, true, "获取成功", data)
}
// CardStatsTrend30DaysHandler 卡密30天趋势API
// - 返回近30天的卡密创建和使用趋势
func CardStatsTrend30DaysHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
// 生成近30天的日期列表
var dates []string
var totalCounts []int64
var usedCounts []int64
var unusedCounts []int64
for i := 29; i >= 0; i-- {
date := time.Now().AddDate(0, 0, -i).Format("2006-01-02")
dates = append(dates, date)
dayStart := date + " 00:00:00"
dayEnd := date + " 23:59:59"
// 当天创建的卡密总数
var totalCount int64
db.Model(&models.Card{}).Where("created_at >= ? AND created_at <= ?", dayStart, dayEnd).Count(&totalCount)
totalCounts = append(totalCounts, totalCount)
// 当天创建且已使用的卡密数
var usedCount int64
db.Model(&models.Card{}).
Where("created_at >= ? AND created_at <= ? AND status = ?", dayStart, dayEnd, constants.CardStatusUsed).
Count(&usedCount)
usedCounts = append(usedCounts, usedCount)
// 当天创建且未使用的卡密数
var unusedCount int64
db.Model(&models.Card{}).
Where("created_at >= ? AND created_at <= ? AND status = ?", dayStart, dayEnd, constants.CardStatusUnused).
Count(&unusedCount)
unusedCounts = append(unusedCounts, unusedCount)
}
// 构建响应数据
data := map[string]interface{}{
"dates": dates,
"total": totalCounts,
"used": usedCounts,
"unused": unusedCounts,
}
utils.JsonResponse(w, http.StatusOK, true, "获取成功", data)
}
// CardStatsSimpleHandler 简单卡密统计API
// - 返回卡密的基本统计信息:总数、已使用、未使用、禁用
func CardStatsSimpleHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
// 统计各状态的卡密数量
var total int64
var used int64
var unused int64
var disabled int64
db.Model(&models.Card{}).Count(&total)
db.Model(&models.Card{}).Where("status = ?", constants.CardStatusUsed).Count(&used)
db.Model(&models.Card{}).Where("status = ?", constants.CardStatusUnused).Count(&unused)
db.Model(&models.Card{}).Where("status = ?", constants.CardStatusDisabled).Count(&disabled)
data := map[string]interface{}{
"total": total,
"used": used,
"unused": unused,
"disabled": disabled,
}
utils.JsonResponse(w, http.StatusOK, true, "获取成功", data)
}

View File

@@ -0,0 +1,428 @@
package admin
import (
"encoding/json"
"net/http"
"networkDev/database"
"networkDev/models"
"networkDev/utils"
"strconv"
"strings"
)
// CardTypesFragmentHandler 卡密类型管理片段渲染
// - 渲染 card_types.html 列表与表单界面
func CardTypesFragmentHandler(w http.ResponseWriter, r *http.Request) {
utils.RenderTemplate(w, "card_types.html", map[string]interface{}{})
}
// CardTypesListHandler 获取卡密类型列表
// - 支持GET
// - 支持分页和筛选
func CardTypesListHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
// 获取查询参数
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
pageSize, _ := strconv.Atoi(r.URL.Query().Get("page_size"))
keyword := r.URL.Query().Get("keyword")
statusStr := r.URL.Query().Get("status")
// 设置默认分页参数
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 100 {
pageSize = 20
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
// 构建查询条件
query := db.Model(&models.CardType{})
// 筛选条件
if keyword != "" {
query = query.Where("name LIKE ?", "%"+keyword+"%")
}
if statusStr != "" {
if status, err := strconv.Atoi(statusStr); err == nil {
query = query.Where("status = ?", status)
}
}
// 计算总数
var total int64
if err := query.Count(&total).Error; err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "统计总数失败", nil)
return
}
// 分页查询
var items []models.CardType
offset := (page - 1) * pageSize
if err := query.Order("id asc").Offset(offset).Limit(pageSize).Find(&items).Error; err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "查询失败", nil)
return
}
// 返回分页数据
result := map[string]interface{}{
"items": items,
"total": total,
"page": page,
"page_size": pageSize,
"pages": (total + int64(pageSize) - 1) / int64(pageSize),
}
utils.JsonResponse(w, http.StatusOK, true, "ok", result)
}
// CardTypeCreateHandler 新增卡密类型
// - 接收JSON: {name, status, login_types}
// - Name 必填且唯一
func CardTypeCreateHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
type reqBody struct {
Name string `json:"name"`
Status int `json:"status"`
LoginTypes string `json:"login_types"`
}
var body reqBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
utils.JsonResponse(w, http.StatusBadRequest, false, "请求体错误", nil)
return
}
if body.Name == "" {
utils.JsonResponse(w, http.StatusBadRequest, false, "名称不能为空", nil)
return
}
// 校验登录方式ID是否存在
if errMsg := validateLoginTypes(body.LoginTypes); errMsg != "" {
utils.JsonResponse(w, http.StatusBadRequest, false, errMsg, nil)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
item := models.CardType{
Name: body.Name,
Status: body.Status,
LoginTypes: body.LoginTypes,
}
if item.Status != 0 {
item.Status = 1
}
if err := db.Create(&item).Error; err != nil {
utils.JsonResponse(w, http.StatusBadRequest, false, "创建失败,可能是名称重复", nil)
return
}
utils.JsonResponse(w, http.StatusOK, true, "创建成功", item)
}
// checkCardTypeInUse 检查卡密类型是否被卡密使用
// - 通过 cards 表中 card_type_id 外键计数
// - 返回是否被使用以及被使用的数量
func checkCardTypeInUse(cardTypeID uint) (bool, int64, error) {
db, err := database.GetDB()
if err != nil {
return false, 0, err
}
var count int64
if err := db.Model(&models.Card{}).Where("card_type_id = ?", cardTypeID).Count(&count).Error; err != nil {
return false, 0, err
}
return count > 0, count, nil
}
// CardTypeUpdateHandler 更新卡密类型
// - 接收JSON: {id, name, status, login_types}
func CardTypeUpdateHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
type reqBody struct {
ID uint `json:"id"`
Name string `json:"name"`
Status int `json:"status"`
LoginTypes string `json:"login_types"`
}
var body reqBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
utils.JsonResponse(w, http.StatusBadRequest, false, "请求体错误", nil)
return
}
if body.ID == 0 {
utils.JsonResponse(w, http.StatusBadRequest, false, "缺少ID", nil)
return
}
// 校验登录方式名称是否存在且未被禁用
if errMsg := validateLoginTypes(body.LoginTypes); errMsg != "" {
utils.JsonResponse(w, http.StatusBadRequest, false, errMsg, nil)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
// 查询原始记录,便于后续在用校验(重命名/禁用)
var original models.CardType
if err := db.First(&original, body.ID).Error; err != nil {
utils.JsonResponse(w, http.StatusBadRequest, false, "卡密类型不存在", nil)
return
}
// 如果名称发生变化且该卡密类型已被卡密使用,则不允许修改名称
if body.Name != "" && body.Name != original.Name {
inUse, count, err := checkCardTypeInUse(body.ID)
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "检查使用状态失败", nil)
return
}
if inUse {
utils.JsonResponse(w, http.StatusBadRequest, false, "该卡密类型已被卡密使用(数量:"+strconv.FormatInt(count, 10)+"),无法修改名称", nil)
return
}
}
// 当尝试禁用status=0且原状态不是禁用时如该类型已被卡密使用则禁止禁用
if body.Status == 0 && original.Status != 0 {
inUse, count, err := checkCardTypeInUse(body.ID)
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "检查使用状态失败", nil)
return
}
if inUse {
utils.JsonResponse(w, http.StatusBadRequest, false, "该卡密类型已被卡密使用(数量:"+strconv.FormatInt(count, 10)+"),无法禁用", nil)
return
}
}
// 构建更新字段
updates := map[string]interface{}{}
if body.Name != "" {
updates["name"] = body.Name
}
updates["status"] = body.Status
updates["login_types"] = body.LoginTypes
if err := db.Model(&models.CardType{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil {
utils.JsonResponse(w, http.StatusBadRequest, false, "更新失败,可能是名称重复", nil)
return
}
utils.JsonResponse(w, http.StatusOK, true, "更新成功", nil)
}
// CardTypeDeleteHandler 删除单个卡密类型
// - 接收JSON: {id}
func CardTypeDeleteHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
ID uint `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ID == 0 {
utils.JsonResponse(w, http.StatusBadRequest, false, "参数错误", nil)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
// 在用校验
inUse, count, err := checkCardTypeInUse(body.ID)
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "检查使用状态失败", nil)
return
}
if inUse {
utils.JsonResponse(w, http.StatusBadRequest, false, "该卡密类型已被卡密使用(数量:"+strconv.FormatInt(count, 10)+"),无法删除", nil)
return
}
if err := db.Delete(&models.CardType{}, body.ID).Error; err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "删除失败", nil)
return
}
utils.JsonResponse(w, http.StatusOK, true, "删除成功", nil)
}
// CardTypesBatchDeleteHandler 批量删除卡密类型
// - 接收JSON: {ids: []}
func CardTypesBatchDeleteHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
IDs []uint `json:"ids"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.IDs) == 0 {
utils.JsonResponse(w, http.StatusBadRequest, false, "参数错误", nil)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
// 批量在用校验
var blocking []string
for _, id := range body.IDs {
inUse, count, err := checkCardTypeInUse(id)
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "检查使用状态失败", nil)
return
}
if inUse {
var ct models.CardType
if db.First(&ct, id).Error == nil {
blocking = append(blocking, ct.Name+"(数量:"+strconv.FormatInt(count, 10)+"")
} else {
blocking = append(blocking, strconv.FormatUint(uint64(id), 10)+"(数量:"+strconv.FormatInt(count, 10)+"")
}
}
}
if len(blocking) > 0 {
utils.JsonResponse(w, http.StatusBadRequest, false, "以下卡密类型已被卡密使用,无法删除:"+strings.Join(blocking, ""), nil)
return
}
if err := db.Delete(&models.CardType{}, body.IDs).Error; err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "批量删除失败", nil)
return
}
utils.JsonResponse(w, http.StatusOK, true, "批量删除成功", nil)
}
// validateLoginTypes 校验登录方式名称是否存在且未被禁用
// - 接收逗号分隔的登录方式名称字符串
// - 检查登录方式是否存在且状态为启用(status=1)
// - 返回错误信息,如果所有名称都存在且启用则返回空字符串
func validateLoginTypes(loginTypesStr string) string {
if loginTypesStr == "" {
return ""
}
// 分割登录方式名称字符串
nameStrs := strings.Split(loginTypesStr, ",")
var names []string
// 去重并清理空格
nameSet := make(map[string]bool)
for _, nameStr := range nameStrs {
nameStr = strings.TrimSpace(nameStr)
if nameStr == "" {
continue
}
nameSet[nameStr] = true
}
// 转换为切片
for name := range nameSet {
names = append(names, name)
}
if len(names) == 0 {
return ""
}
// 查询数据库检查名称是否存在
db, err := database.GetDB()
if err != nil {
return "数据库连接失败"
}
// 查询所有匹配的登录方式,包括状态信息
var loginTypes []models.LoginType
if err := db.Where("name IN ?", names).Find(&loginTypes).Error; err != nil {
return "查询登录方式失败"
}
// 检查是否有不存在的名称和被禁用的登录方式
existingSet := make(map[string]bool)
disabledNames := []string{}
for _, loginType := range loginTypes {
existingSet[loginType.Name] = true
// 检查登录方式是否被禁用 (status != 1 表示禁用)
if loginType.Status != 1 {
disabledNames = append(disabledNames, loginType.Name)
}
}
// 检查不存在的名称
var notFoundNames []string
for _, name := range names {
if !existingSet[name] {
notFoundNames = append(notFoundNames, name)
}
}
// 返回错误信息
if len(notFoundNames) > 0 {
return "以下登录方式名称不存在: " + strings.Join(notFoundNames, ", ")
}
if len(disabledNames) > 0 {
return "以下登录方式已被禁用,无法使用: " + strings.Join(disabledNames, ", ")
}
return ""
}
// CardTypesBatchEnableHandler 批量启用
// - 接收JSON: {ids: []}
func CardTypesBatchEnableHandler(w http.ResponseWriter, r *http.Request) {
batchUpdateStatus(w, r, 1)
}
// CardTypesBatchDisableHandler 批量禁用
// - 接收JSON: {ids: []}
func CardTypesBatchDisableHandler(w http.ResponseWriter, r *http.Request) {
batchUpdateStatus(w, r, 0)
}
// batchUpdateStatus 批量更新状态的通用函数
// - status: 1 启用0 禁用
func batchUpdateStatus(w http.ResponseWriter, r *http.Request, status int) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
IDs []uint `json:"ids"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.IDs) == 0 {
utils.JsonResponse(w, http.StatusBadRequest, false, "参数错误", nil)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
if err := db.Model(&models.CardType{}).Where("id IN ?", body.IDs).Update("status", status).Error; err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "批量更新失败", nil)
return
}
utils.JsonResponse(w, http.StatusOK, true, "操作成功", nil)
}

View File

@@ -0,0 +1,91 @@
package admin
import (
"net/http"
"networkDev/database"
"networkDev/services"
"networkDev/utils"
"networkDev/utils/timeutil"
"github.com/spf13/viper"
)
// AdminIndexHandler /admin 与 /admin/ 根路径入口
// - 未登录:重定向到 /admin/login
// - 已登录:渲染后台布局页(或重定向到 /admin/layout
func AdminIndexHandler(w http.ResponseWriter, r *http.Request) {
if IsAdminAuthenticated(r) {
// 直接渲染布局页保持URL为 /admin
AdminLayoutHandler(w, r)
return
}
http.Redirect(w, r, "/admin/login", http.StatusFound)
}
// AdminLayoutHandler 后台布局页渲染
// - 渲染 layout.html包含顶部导航、侧边栏与动态内容容器
func AdminLayoutHandler(w http.ResponseWriter, r *http.Request) {
data := utils.GetDefaultTemplateData()
// 从数据库读取站点标题
db, err := database.GetDB()
if err != nil {
data["Title"] = "凌动技术"
} else {
siteTitle, err := services.FindSettingByName("site_title", db)
if err != nil || siteTitle == nil {
data["Title"] = "凌动技术"
} else {
data["Title"] = siteTitle.Value
}
}
utils.RenderTemplate(w, "layout.html", data)
}
// DashboardFragmentHandler 仪表盘片段渲染
// - 展示系统信息:版本、运行模式、数据库类型、启动时长
func DashboardFragmentHandler(w http.ResponseWriter, r *http.Request) {
version := "1.0.0"
mode := viper.GetString("server.mode")
dbType := viper.GetString("database.type")
if dbType == "" {
dbType = "sqlite"
}
uptime := timeutil.GetServerUptimeString()
data := map[string]interface{}{
"Version": version,
"Mode": mode,
"DBType": dbType,
"Uptime": uptime,
}
utils.RenderTemplate(w, "dashboard.html", data)
}
// SystemInfoHandler 系统信息API接口
// - 返回系统运行状态的JSON数据用于前端定时刷新
func SystemInfoHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
version := "1.0.0"
mode := viper.GetString("server.mode")
dbType := viper.GetString("database.type")
if dbType == "" {
dbType = "sqlite"
}
uptime := timeutil.GetServerUptimeString()
data := map[string]interface{}{
"version": version,
"mode": mode,
"db_type": dbType,
"uptime": uptime,
}
utils.JsonResponse(w, http.StatusOK, true, "ok", data)
}

View File

@@ -0,0 +1,394 @@
package admin
import (
"encoding/json"
"net/http"
"networkDev/database"
"networkDev/models"
"networkDev/utils"
"strconv"
"strings"
)
// LoginTypesFragmentHandler 登录方式管理片段渲染
// - 渲染 login_types.html 列表与表单界面
func LoginTypesFragmentHandler(w http.ResponseWriter, r *http.Request) {
utils.RenderTemplate(w, "login_types.html", map[string]interface{}{})
}
// LoginTypesListHandler 获取登录方式列表
// - 支持GET
// - 支持分页和筛选
func LoginTypesListHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
// 获取查询参数
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
pageSize, _ := strconv.Atoi(r.URL.Query().Get("page_size"))
keyword := r.URL.Query().Get("keyword")
statusStr := r.URL.Query().Get("status")
// 设置默认分页参数
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 100 {
pageSize = 20
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
// 构建查询条件
query := db.Model(&models.LoginType{})
// 筛选条件
if keyword != "" {
query = query.Where("name LIKE ?", "%"+keyword+"%")
}
if statusStr != "" {
if status, err := strconv.Atoi(statusStr); err == nil {
query = query.Where("status = ?", status)
}
}
// 计算总数
var total int64
if err := query.Count(&total).Error; err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "统计总数失败", nil)
return
}
// 分页查询
var items []models.LoginType
offset := (page - 1) * pageSize
if err := query.Order("id asc").Offset(offset).Limit(pageSize).Find(&items).Error; err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "查询失败", nil)
return
}
// 返回分页数据
result := map[string]interface{}{
"items": items,
"total": total,
"page": page,
"page_size": pageSize,
"pages": (total + int64(pageSize) - 1) / int64(pageSize),
}
utils.JsonResponse(w, http.StatusOK, true, "ok", result)
}
// LoginTypeCreateHandler 新增登录方式
// - 接收JSON: {name, description, status}
// - Name 必填且唯一
func LoginTypeCreateHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
type reqBody struct {
Name string `json:"name"`
VerifyTypes string `json:"verify_types"`
Status int `json:"status"`
}
var body reqBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
utils.JsonResponse(w, http.StatusBadRequest, false, "请求体错误", nil)
return
}
if body.Name == "" {
utils.JsonResponse(w, http.StatusBadRequest, false, "名称不能为空", nil)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
item := models.LoginType{
Name: body.Name,
Status: body.Status,
VerifyTypes: body.VerifyTypes,
}
if item.Status != 0 {
item.Status = 1
}
if err := db.Create(&item).Error; err != nil {
utils.JsonResponse(w, http.StatusBadRequest, false, "创建失败,可能是名称重复", nil)
return
}
utils.JsonResponse(w, http.StatusOK, true, "创建成功", item)
}
// checkLoginTypeInUse 检查登录类型是否被卡密类型使用
// - 检查 card_types 表中的 login_types 字段是否包含该登录类型名称
// - 返回是否被使用和使用该登录类型的卡密类型名称列表
func checkLoginTypeInUse(loginTypeName string) (bool, []string, error) {
db, err := database.GetDB()
if err != nil {
return false, nil, err
}
var cardTypes []models.CardType
// 查询包含该登录类型名称的卡密类型
if err := db.Where("login_types LIKE ?", "%"+loginTypeName+"%").Find(&cardTypes).Error; err != nil {
return false, nil, err
}
var usingCardTypes []string
for _, cardType := range cardTypes {
// 精确匹配登录类型名称(避免部分匹配)
loginTypes := strings.Split(cardType.LoginTypes, ",")
for _, lt := range loginTypes {
if strings.TrimSpace(lt) == loginTypeName {
usingCardTypes = append(usingCardTypes, cardType.Name)
break
}
}
}
return len(usingCardTypes) > 0, usingCardTypes, nil
}
// checkLoginTypesByIDsInUse 批量检查登录类型ID是否被使用
// - 先查询登录类型ID对应的名称再检查是否被使用
func checkLoginTypesByIDsInUse(loginTypeIDs []uint) (bool, map[uint][]string, error) {
db, err := database.GetDB()
if err != nil {
return false, nil, err
}
// 查询登录类型名称
var loginTypes []models.LoginType
if err := db.Where("id IN ?", loginTypeIDs).Find(&loginTypes).Error; err != nil {
return false, nil, err
}
hasUsage := false
usageMap := make(map[uint][]string)
for _, loginType := range loginTypes {
inUse, usingCardTypes, err := checkLoginTypeInUse(loginType.Name)
if err != nil {
return false, nil, err
}
if inUse {
hasUsage = true
usageMap[loginType.ID] = usingCardTypes
}
}
return hasUsage, usageMap, nil
}
// LoginTypeUpdateHandler 更新登录方式
// - 接收JSON: {id, name, description, status}
func LoginTypeUpdateHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
type reqBody struct {
ID uint `json:"id"`
Name string `json:"name"`
VerifyTypes string `json:"verify_types"`
Status int `json:"status"`
}
var body reqBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
utils.JsonResponse(w, http.StatusBadRequest, false, "请求体错误", nil)
return
}
if body.ID == 0 {
utils.JsonResponse(w, http.StatusBadRequest, false, "缺少ID", nil)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
// 始终查询原始记录,便于后续校验(重命名/禁用)
var originalLoginType models.LoginType
if err := db.First(&originalLoginType, body.ID).Error; err != nil {
utils.JsonResponse(w, http.StatusBadRequest, false, "登录类型不存在", nil)
return
}
// 如果名称发生变化,检查原名称是否被使用(与删除逻辑一致)
if body.Name != "" && originalLoginType.Name != body.Name {
inUse, usingCardTypes, err := checkLoginTypeInUse(originalLoginType.Name)
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "检查使用状态失败", nil)
return
}
if inUse {
utils.JsonResponse(w, http.StatusBadRequest, false, "该登录类型正在被以下卡密类型使用,无法修改名称:"+strings.Join(usingCardTypes, "、"), nil)
return
}
}
// 当尝试禁用status=0如被卡密类型使用则禁止禁用
if body.Status == 0 && originalLoginType.Status != 0 {
inUse, usingCardTypes, err := checkLoginTypeInUse(originalLoginType.Name)
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "检查使用状态失败", nil)
return
}
if inUse {
utils.JsonResponse(w, http.StatusBadRequest, false, "该登录类型正在被以下卡密类型使用,无法禁用:"+strings.Join(usingCardTypes, "、"), nil)
return
}
}
updates := map[string]interface{}{}
if body.Name != "" {
updates["name"] = body.Name
}
updates["status"] = body.Status
updates["verify_types"] = body.VerifyTypes
if err := db.Model(&models.LoginType{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil {
utils.JsonResponse(w, http.StatusBadRequest, false, "更新失败,可能是名称重复", nil)
return
}
utils.JsonResponse(w, http.StatusOK, true, "更新成功", nil)
}
// LoginTypeDeleteHandler 删除单个登录方式
// - 接收JSON: {id}
func LoginTypeDeleteHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
ID uint `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ID == 0 {
utils.JsonResponse(w, http.StatusBadRequest, false, "参数错误", nil)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
// 查询登录类型名称
var loginType models.LoginType
if dbErr := db.First(&loginType, body.ID).Error; dbErr != nil {
utils.JsonResponse(w, http.StatusBadRequest, false, "登录类型不存在", nil)
return
}
// 检查是否被卡密类型使用
inUse, usingCardTypes, err := checkLoginTypeInUse(loginType.Name)
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "检查使用状态失败", nil)
return
}
if inUse {
utils.JsonResponse(w, http.StatusBadRequest, false, "该登录类型正在被以下卡密类型使用,无法删除:"+strings.Join(usingCardTypes, "、"), nil)
return
}
if err := db.Delete(&models.LoginType{}, body.ID).Error; err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "删除失败", nil)
return
}
utils.JsonResponse(w, http.StatusOK, true, "删除成功", nil)
}
// LoginTypesBatchDeleteHandler 批量删除登录方式
// - 接收JSON: {ids: []}
func LoginTypesBatchDeleteHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
IDs []uint `json:"ids"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.IDs) == 0 {
utils.JsonResponse(w, http.StatusBadRequest, false, "参数错误", nil)
return
}
// 检查批量删除的登录类型是否被使用
hasUsage, usageMap, err := checkLoginTypesByIDsInUse(body.IDs)
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "检查使用状态失败", nil)
return
}
if hasUsage {
// 构建详细的错误信息
var errorMessages []string
db, _ := database.GetDB()
for loginTypeID, usingCardTypes := range usageMap {
var loginType models.LoginType
if db.First(&loginType, loginTypeID).Error == nil {
errorMessages = append(errorMessages, loginType.Name+"(被"+strings.Join(usingCardTypes, "、")+"使用)")
}
}
utils.JsonResponse(w, http.StatusBadRequest, false, "以下登录类型正在被使用,无法删除:"+strings.Join(errorMessages, ""), nil)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
if err := db.Delete(&models.LoginType{}, body.IDs).Error; err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "批量删除失败", nil)
return
}
utils.JsonResponse(w, http.StatusOK, true, "批量删除成功", nil)
}
// LoginTypesBatchEnableHandler 批量启用
// - 接收JSON: {ids: []}
func LoginTypesBatchEnableHandler(w http.ResponseWriter, r *http.Request) {
batchUpdateLoginTypeStatus(w, r, 1)
}
// LoginTypesBatchDisableHandler 批量禁用
// - 接收JSON: {ids: []}
func LoginTypesBatchDisableHandler(w http.ResponseWriter, r *http.Request) {
batchUpdateLoginTypeStatus(w, r, 0)
}
// batchUpdateLoginTypeStatus 批量更新登录方式状态的通用函数
// - status: 1 启用0 禁用
func batchUpdateLoginTypeStatus(w http.ResponseWriter, r *http.Request, status int) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
IDs []uint `json:"ids"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.IDs) == 0 {
utils.JsonResponse(w, http.StatusBadRequest, false, "参数错误", nil)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
if err := db.Model(&models.LoginType{}).Where("id IN ?", body.IDs).Update("status", status).Error; err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "批量更新失败", nil)
return
}
utils.JsonResponse(w, http.StatusOK, true, "操作成功", nil)
}

View File

@@ -0,0 +1,147 @@
package admin
import (
"encoding/json"
"fmt"
"net/http"
"networkDev/database"
"networkDev/models"
"networkDev/utils"
// 新增:用于刷新内存缓存
"networkDev/services"
// 新增用于RedisDel上下文
"context"
"github.com/sirupsen/logrus"
)
// SettingsFragmentHandler 设置片段渲染
// - 渲染设置表单通过前端JS调用API加载/保存)
func SettingsFragmentHandler(w http.ResponseWriter, r *http.Request) {
utils.RenderTemplate(w, "settings.html", map[string]interface{}{})
}
// SettingsQueryHandler 设置查询API
// - 返回所有设置项的 name:value 映射
func SettingsQueryHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
var list []models.Settings
if err := db.Find(&list).Error; err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "查询失败", nil)
return
}
res := map[string]string{}
for _, s := range list {
res[s.Name] = s.Value
}
utils.JsonResponse(w, http.StatusOK, true, "ok", res)
}
// SettingsUpdateHandler 更新系统设置处理器
// - 接收JSON格式的设置数据支持两种格式
// 1. 直接字段格式: {"site_title": "值", "site_keywords": "值"}
// 2. 嵌套格式: {"settings": {"site_title": "值", "site_keywords": "值"}}
//
// - 自动创建不存在的设置项
// - 更新已存在的设置项
// - 更新完成后:
// 1. 删除对应的Redis缓存键确保后续读取走数据库并重建缓存
// 2. 刷新SettingsService内存缓存
func SettingsUpdateHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
// 先尝试解析为直接字段格式
var directBody map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&directBody); err != nil {
utils.JsonResponse(w, http.StatusBadRequest, false, "请求体错误", nil)
return
}
// 提取设置数据
var settingsData map[string]string
// 检查是否为嵌套格式包含settings字段
if settings, exists := directBody["settings"]; exists {
if settingsMap, ok := settings.(map[string]interface{}); ok {
settingsData = make(map[string]string)
for k, v := range settingsMap {
if str, ok := v.(string); ok {
settingsData[k] = str
}
}
} else {
utils.JsonResponse(w, http.StatusBadRequest, false, "settings字段格式错误", nil)
return
}
} else {
// 直接字段格式
settingsData = make(map[string]string)
for k, v := range directBody {
if str, ok := v.(string); ok {
settingsData[k] = str
} else if v != nil {
// 转换其他类型为字符串
settingsData[k] = fmt.Sprintf("%v", v)
}
}
}
if len(settingsData) == 0 {
utils.JsonResponse(w, http.StatusBadRequest, false, "无设置项", nil)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
// 记录需要失效的缓存键统一删除减少与Redis交互次数
keysToDel := make([]string, 0, len(settingsData))
// 批量处理设置项
for k, v := range settingsData {
var s models.Settings
if err := db.Where("name = ?", k).First(&s).Error; err != nil {
// 不存在则创建
s = models.Settings{Name: k, Value: v}
if err := db.Create(&s).Error; err != nil {
logrus.WithError(err).WithField("setting_name", k).Error("创建设置失败")
utils.JsonResponse(w, http.StatusInternalServerError, false, fmt.Sprintf("保存设置 %s 失败", k), nil)
return
}
} else {
// 存在则更新
if err := db.Model(&models.Settings{}).Where("id = ?", s.ID).Update("value", v).Error; err != nil {
logrus.WithError(err).WithField("setting_name", k).Error("更新设置失败")
utils.JsonResponse(w, http.StatusInternalServerError, false, fmt.Sprintf("更新设置 %s 失败", k), nil)
return
}
}
// 收集对应的Redis缓存键与services/query.go中的键命名保持一致
keysToDel = append(keysToDel, fmt.Sprintf("setting:%s", k))
}
// 删除Redis缓存键如果Redis不可用则静默跳过
_ = utils.RedisDel(context.Background(), keysToDel...)
// 刷新内存中的设置缓存,保证后续读取一致
services.GetSettingsService().RefreshCache()
utils.JsonResponse(w, http.StatusOK, true, "保存成功", nil)
}

238
controllers/admin/user.go Normal file
View File

@@ -0,0 +1,238 @@
package admin
import (
"encoding/json"
"net/http"
"networkDev/database"
"networkDev/models"
"networkDev/utils"
"strings"
)
// UserFragmentHandler 个人资料片段渲染
// - 渲染个人资料与修改密码表单
func UserFragmentHandler(w http.ResponseWriter, r *http.Request) {
utils.RenderTemplate(w, "user.html", map[string]interface{}{})
}
// UserProfileQueryHandler 查询当前登录管理员的基本信息
// - 返回 id/username/role 三个字段
// - 自动刷新接近过期的JWT令牌
func UserProfileQueryHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
claims, _, err := GetCurrentAdminUserWithRefresh(w, r)
if err != nil {
utils.JsonResponse(w, http.StatusUnauthorized, false, "未登录或会话已过期", nil)
return
}
utils.JsonResponse(w, http.StatusOK, true, "ok", map[string]interface{}{
"id": claims.UserID,
"username": claims.Username,
"role": claims.Role,
})
}
// UserPasswordUpdateHandler 修改当前登录管理员的密码
// - 接收 JSON: {old_password, new_password, confirm_password}
// - 校验旧密码正确性、新密码与确认一致性
// - 成功后更新密码哈希
// - 自动刷新接近过期的JWT令牌
func UserPasswordUpdateHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
claims, _, err := GetCurrentAdminUserWithRefresh(w, r)
if err != nil {
utils.JsonResponse(w, http.StatusUnauthorized, false, "未登录或会话已过期", nil)
return
}
var body struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
ConfirmPassword string `json:"confirm_password"`
}
var decodeErr error
if decodeErr = json.NewDecoder(r.Body).Decode(&body); decodeErr != nil {
utils.JsonResponse(w, http.StatusBadRequest, false, "请求参数错误", nil)
return
}
// 基础校验
if body.OldPassword == "" || body.NewPassword == "" || body.ConfirmPassword == "" {
utils.JsonResponse(w, http.StatusBadRequest, false, "旧密码/新密码/确认密码均不能为空", nil)
return
}
if len(body.NewPassword) < 6 {
utils.JsonResponse(w, http.StatusBadRequest, false, "新密码长度不能少于6位", nil)
return
}
if body.NewPassword != body.ConfirmPassword {
utils.JsonResponse(w, http.StatusBadRequest, false, "两次输入的新密码不一致", nil)
return
}
if body.NewPassword == body.OldPassword {
utils.JsonResponse(w, http.StatusBadRequest, false, "新密码不能与旧密码相同", nil)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
// 查询当前用户
var user models.User
if dbErr := db.First(&user, claims.UserID).Error; dbErr != nil {
utils.JsonResponse(w, http.StatusNotFound, false, "用户不存在", nil)
return
}
// 校验旧密码(使用盐值验证)
if !utils.VerifyPasswordWithSalt(body.OldPassword, user.PasswordSalt, user.Password) {
utils.JsonResponse(w, http.StatusUnauthorized, false, "旧密码不正确", nil)
return
}
// 生成新的密码盐值
newSalt, err := utils.GenerateRandomSalt()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "生成密码盐失败", nil)
return
}
// 使用新盐值生成密码哈希
hash, err := utils.HashPasswordWithSalt(body.NewPassword, newSalt)
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "生成密码哈希失败", nil)
return
}
// 更新密码和盐值
if err := db.Model(&models.User{}).Where("id = ?", claims.UserID).Updates(map[string]interface{}{
"password": hash,
"password_salt": newSalt,
}).Error; err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "更新密码失败", nil)
return
}
// 可选:安全起见,通知前端跳转到登录页
utils.JsonResponse(w, http.StatusOK, true, "密码修改成功,请重新登录", map[string]interface{}{
"redirect": "/admin/login",
})
}
// UserProfileUpdateHandler 修改当前登录管理员的用户名
// - 接收 JSON: {username}
// - 校验用户名非空、长度与唯一性
// - 更新数据库后重新签发JWT并写入 Cookie保持前端展示的一致性
// - 自动刷新接近过期的JWT令牌
func UserProfileUpdateHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
claims, _, err := GetCurrentAdminUserWithRefresh(w, r)
if err != nil {
utils.JsonResponse(w, http.StatusUnauthorized, false, "未登录或会话已过期", nil)
return
}
var body struct {
Username string `json:"username"`
OldPassword string `json:"old_password"`
}
if decodeErr := json.NewDecoder(r.Body).Decode(&body); decodeErr != nil {
utils.JsonResponse(w, http.StatusBadRequest, false, "请求参数错误", nil)
return
}
username := strings.TrimSpace(body.Username)
if username == "" {
utils.JsonResponse(w, http.StatusBadRequest, false, "用户名不能为空", nil)
return
}
if len(username) > 64 {
utils.JsonResponse(w, http.StatusBadRequest, false, "用户名长度不能超过64字符", nil)
return
}
db, err := database.GetDB()
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil)
return
}
// 检查唯一性排除当前用户ID
var cnt int64
if dbErr := db.Model(&models.User{}).Where("username = ? AND id <> ?", username, claims.UserID).Count(&cnt).Error; dbErr != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "检查用户名唯一性失败", nil)
return
}
if cnt > 0 {
utils.JsonResponse(w, http.StatusBadRequest, false, "用户名已存在,请更换", nil)
return
}
// 如果未变化则直接返回成功(无需校验旧密码)
if strings.EqualFold(username, claims.Username) {
utils.JsonResponse(w, http.StatusOK, true, "保存成功", map[string]interface{}{
"username": username,
})
return
}
// 修改用户名需要进行当前密码校验
if strings.TrimSpace(body.OldPassword) == "" {
utils.JsonResponse(w, http.StatusBadRequest, false, "修改用户名需要提供当前密码", nil)
return
}
// 查询当前用户并校验旧密码
var user models.User
if dbErr := db.First(&user, claims.UserID).Error; dbErr != nil {
utils.JsonResponse(w, http.StatusNotFound, false, "用户不存在", nil)
return
}
// 使用盐值验证当前密码
if !utils.VerifyPasswordWithSalt(body.OldPassword, user.PasswordSalt, user.Password) {
utils.JsonResponse(w, http.StatusUnauthorized, false, "当前密码不正确", nil)
return
}
// 执行更新
if dbErr := db.Model(&models.User{}).Where("id = ?", claims.UserID).Update("username", username).Error; dbErr != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "更新用户名失败", nil)
return
}
// 重新签发JWT并写入Cookie
newUser := models.User{ID: claims.UserID, Username: username, Role: claims.Role}
token, err := generateJWTToken(newUser)
if err != nil {
utils.JsonResponse(w, http.StatusInternalServerError, false, "生成新令牌失败", nil)
return
}
cookie := &http.Cookie{
Name: "admin_session",
Value: token,
Path: "/",
HttpOnly: true,
Secure: false,
MaxAge: 24 * 60 * 60,
}
http.SetCookie(w, cookie)
utils.JsonResponse(w, http.StatusOK, true, "保存成功", map[string]interface{}{
"username": username,
})
}

73
controllers/home/home.go Normal file
View File

@@ -0,0 +1,73 @@
package home
import (
"net/http"
"networkDev/database"
"networkDev/models"
"networkDev/services"
"networkDev/utils"
)
// RootHandler 主页处理器
func RootHandler(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
// 获取数据库连接
db, err := database.GetDB()
if err != nil {
http.Error(w, "数据库连接失败", http.StatusInternalServerError)
return
}
// 从数据库获取站点标题和页脚文本
siteTitle, err := services.FindSettingByName("site_title", db)
if err != nil {
siteTitle = &models.Settings{Value: "凌动技术"}
}
footerText, err := services.FindSettingByName("footer_text", db)
if err != nil {
footerText = &models.Settings{Value: "© 2025 凌动技术 保留所有权利"}
}
// 从数据库获取备案信息
icpRecord, err := services.FindSettingByName("icp_record", db)
if err != nil {
icpRecord = &models.Settings{Value: ""}
}
icpRecordLink, err := services.FindSettingByName("icp_record_link", db)
if err != nil {
icpRecordLink = &models.Settings{Value: "https://beian.miit.gov.cn"}
}
// 从数据库获取公安备案信息
psbRecord, err := services.FindSettingByName("psb_record", db)
if err != nil {
psbRecord = &models.Settings{Value: ""}
}
psbRecordLink, err := services.FindSettingByName("psb_record_link", db)
if err != nil {
psbRecordLink = &models.Settings{Value: "https://www.beian.gov.cn"}
}
// 准备模板数据
data := map[string]interface{}{
"SystemName": siteTitle.Value,
"FooterText": footerText.Value,
"ICPRecord": icpRecord.Value,
"ICPRecordLink": icpRecordLink.Value,
"PSBRecord": psbRecord.Value,
"PSBRecordLink": psbRecordLink.Value,
"title": "主页",
}
if err := utils.RenderTemplate(w, "index.html", data); err != nil {
http.Error(w, "页面加载失败", http.StatusInternalServerError)
return
}
}

125
database/database.go Normal file
View File

@@ -0,0 +1,125 @@
package database
import (
"fmt"
"sync"
"networkDev/utils"
"github.com/glebarez/sqlite"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
var (
// dbInstance 全局 *gorm.DB 实例,使用单例确保全局复用
dbInstance *gorm.DB
// once 确保初始化只执行一次
once sync.Once
)
// Init 初始化数据库连接(根据配置自动选择驱动)
// - 默认使用 SQLitegithub.com/glebarez/sqlite
// - 生产环境支持 MySQLgorm.io/driver/mysql
func Init() (*gorm.DB, error) {
var initErr error
once.Do(func() {
dbType := viper.GetString("database.type")
switch dbType {
case "mysql":
initErr = initMySQL()
default:
initErr = initSQLite()
}
// 如果数据库初始化成功,配置连接池和启动健康检查
if initErr == nil && dbInstance != nil {
// 加载数据库配置
var configPrefix string
if dbType == "mysql" {
configPrefix = "database.mysql"
} else {
configPrefix = "database.sqlite"
}
dbConfig := utils.LoadDatabaseConfig(configPrefix)
// 验证配置
if err := utils.ValidateDatabaseConfig(dbConfig); err != nil {
logrus.WithError(err).Warn("数据库配置验证失败,使用默认配置")
dbConfig = utils.GetDefaultDatabaseConfig()
}
// 配置连接池
if err := utils.ConfigureConnectionPool(dbInstance, dbConfig); err != nil {
logrus.WithError(err).Error("配置数据库连接池失败")
}
// 启动健康检查
utils.StartHealthCheck(dbInstance, dbConfig)
}
})
return dbInstance, initErr
}
// GetDB 获取全局 *gorm.DB 实例
// 如果未初始化,会尝试初始化一次
func GetDB() (*gorm.DB, error) {
if dbInstance != nil {
return dbInstance, nil
}
return Init()
}
// initSQLite 初始化 SQLite 数据库
// 使用 viper 中的 database.sqlite.path 作为数据库文件路径
func initSQLite() error {
path := viper.GetString("database.sqlite.path")
if path == "" {
path = "./recharge.db"
}
dsn := fmt.Sprintf("file:%s?cache=shared&_busy_timeout=5000&_fk=1", path)
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
logrus.WithError(err).Error("SQLite 初始化失败")
return err
}
// SQLite 连接池配置SQLite 对连接池支持有限,但仍可设置基本参数)
if sqlDB, err := db.DB(); err == nil {
// SQLite 通常使用单连接,但可以设置一些基本参数
sqlDB.SetMaxOpenConns(1) // SQLite 建议使用单连接
sqlDB.SetMaxIdleConns(1)
}
dbInstance = db
logrus.WithField("path", path).Info("SQLite 连接已建立")
return nil
}
// initMySQL 初始化 MySQL 数据库
// 从 viper 读取 database.mysql.* 配置构建 DSN
func initMySQL() error {
host := viper.GetString("database.mysql.host")
port := viper.GetInt("database.mysql.port")
user := viper.GetString("database.mysql.username")
pass := viper.GetString("database.mysql.password")
dbname := viper.GetString("database.mysql.database")
charset := viper.GetString("database.mysql.charset")
if charset == "" {
charset = "utf8mb4"
}
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local", user, pass, host, port, dbname, charset)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
logrus.WithError(err).Error("MySQL 初始化失败")
return err
}
dbInstance = db
logrus.WithField("host", host).WithField("database", dbname).Info("MySQL 连接已建立")
return nil
}

172
database/migrate.go Normal file
View File

@@ -0,0 +1,172 @@
package database
import (
"fmt"
"networkDev/models"
"strings"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
// AutoMigrate 自动迁移数据库模型
// - 会确保必要的数据表结构存在
// - 不会破坏已有数据
func AutoMigrate() error {
db, err := GetDB()
if err != nil {
return err
}
if err := db.AutoMigrate(&models.User{}, &models.Settings{}, &models.LoginType{}, &models.CardType{}, &models.Card{}, &models.App{}, &models.API{}); err != nil {
logrus.WithError(err).Error("AutoMigrate 执行失败")
return err
}
// 兼容迁移:如果 users.password_salt 列长度 < 64则扩大到 64
if err := ensureUserPasswordSaltLength(db); err != nil {
logrus.WithError(err).Error("调整 users.password_salt 列长度失败")
return err
}
// 兼容迁移:确保 tasks.verification_code 字段类型为 LONGTEXT 以支持大图片数据
if err := ensureVerificationCodeType(db); err != nil {
logrus.WithError(err).Error("调整 tasks.verification_code 字段类型失败")
return err
}
logrus.Info("AutoMigrate 执行完成")
return nil
}
// ensureVerificationCodeType 确保tasks.verification_code字段类型为LONGTEXT以支持大图片数据
// 中文注释检查并修改verification_code字段类型支持Base64编码的大图片数据存储
func ensureVerificationCodeType(db *gorm.DB) error {
// 获取数据库方言类型
dialector := db.Dialector.Name()
// 根据不同数据库类型执行不同的检查逻辑
switch dialector {
case "mysql":
// MySQL/MariaDB使用INFORMATION_SCHEMA
var result struct {
ColumnName string `gorm:"column:COLUMN_NAME"`
ColumnType string `gorm:"column:COLUMN_TYPE"`
}
err := db.Raw("SELECT COLUMN_NAME, COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1",
"tasks", "verification_code").Scan(&result).Error
if err != nil {
return nil // 查询失败则跳过
}
// 检查列类型如果不是LONGTEXT则修改
if !strings.Contains(strings.ToLower(result.ColumnType), "longtext") {
alterSQL := "ALTER TABLE tasks MODIFY COLUMN verification_code LONGTEXT"
if err := db.Exec(alterSQL).Error; err != nil {
return fmt.Errorf("修改verification_code字段类型失败: %v", err)
}
logrus.Info("verification_code字段类型已更新为LONGTEXT")
}
case "sqlite":
// SQLite使用pragma_table_info检查列信息
var columns []struct {
CID int `gorm:"column:cid"`
Name string `gorm:"column:name"`
Type string `gorm:"column:type"`
NotNull int `gorm:"column:notnull"`
DfltValue *string `gorm:"column:dflt_value"`
PK int `gorm:"column:pk"`
}
err := db.Raw("PRAGMA table_info(tasks)").Scan(&columns).Error
if err != nil {
return nil // 查询失败则跳过
}
// 查找verification_code列
for _, col := range columns {
if col.Name == "verification_code" {
// SQLite中如果列类型不是TEXT需要重建表
if !strings.Contains(strings.ToLower(col.Type), "text") {
// SQLite不支持直接修改列类型但GORM的AutoMigrate会处理这种情况
logrus.Info("SQLite检测到verification_code字段类型需要更新依赖GORM AutoMigrate处理")
}
break
}
}
default:
// 其他数据库类型暂不处理
logrus.Infof("数据库类型 %s 暂不支持verification_code字段类型检查", dialector)
}
return nil
}
// ensureUserPasswordSaltLength 确保users.password_salt列长度至少为64
// 中文注释检查并修改password_salt列长度兼容32字节64十六进制字符的盐值
func ensureUserPasswordSaltLength(db *gorm.DB) error {
// 获取数据库方言类型
dialector := db.Dialector.Name()
// 根据不同数据库类型执行不同的检查逻辑
switch dialector {
case "mysql":
// MySQL/MariaDB使用INFORMATION_SCHEMA
var result struct {
ColumnName string `gorm:"column:COLUMN_NAME"`
ColumnType string `gorm:"column:COLUMN_TYPE"`
}
err := db.Raw("SELECT COLUMN_NAME, COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1",
"users", "password_salt").Scan(&result).Error
if err != nil {
return nil // 查询失败则跳过
}
// 检查列类型如果长度小于64则修改
if strings.Contains(strings.ToLower(result.ColumnType), "varchar") {
if strings.Contains(result.ColumnType, "(32)") || strings.Contains(result.ColumnType, "(16)") {
alterSQL := "ALTER TABLE users MODIFY COLUMN password_salt VARCHAR(64)"
if err := db.Exec(alterSQL).Error; err != nil {
return fmt.Errorf("修改password_salt列长度失败: %v", err)
}
logrus.Info("password_salt列长度已更新为64")
}
}
case "sqlite":
// SQLite使用pragma_table_info检查列信息
var columns []struct {
CID int `gorm:"column:cid"`
Name string `gorm:"column:name"`
Type string `gorm:"column:type"`
NotNull int `gorm:"column:notnull"`
DfltValue *string `gorm:"column:dflt_value"`
PK int `gorm:"column:pk"`
}
err := db.Raw("PRAGMA table_info(users)").Scan(&columns).Error
if err != nil {
return nil // 查询失败则跳过
}
// 查找password_salt列
for _, col := range columns {
if col.Name == "password_salt" {
// SQLite中如果列类型包含长度限制且小于64需要重建表
if strings.Contains(strings.ToLower(col.Type), "varchar(32)") ||
strings.Contains(strings.ToLower(col.Type), "varchar(16)") {
// SQLite不支持直接修改列类型但GORM的AutoMigrate会处理这种情况
logrus.Info("SQLite检测到password_salt列长度需要更新依赖GORM AutoMigrate处理")
}
break
}
}
default:
// 其他数据库类型暂不处理
logrus.Infof("数据库类型 %s 暂不支持password_salt列长度检查", dialector)
}
return nil
}

116
database/seed_settings.go Normal file
View File

@@ -0,0 +1,116 @@
package database
import (
"networkDev/models"
"github.com/sirupsen/logrus"
)
// SeedDefaultSettings 初始化默认系统设置
// - 检查各项设置是否已存在,如不存在则创建默认值
// - 包含站点基本信息、SEO设置等常用配置项
func SeedDefaultSettings() error {
db, err := GetDB()
if err != nil {
return err
}
// 定义默认设置项
defaultSettings := []models.Settings{
{
Name: "site_title",
Value: "凌动技术",
Description: "网站标题,显示在浏览器标题栏和页面顶部",
},
{
Name: "site_keywords",
Value: "验证,网络,管理系统,网络验证,账户管理",
Description: "网站关键词用于SEO优化多个关键词用逗号分隔",
},
{
Name: "site_description",
Value: "专业的网络验证管理系统,提供便捷的在线网络验证服务和账户管理功能",
Description: "网站描述用于SEO优化和社交媒体分享",
},
{
Name: "site_logo",
Value: "/assets/logo.png",
Description: "网站Logo图片路径",
},
{
Name: "contact_email",
Value: "admin@example.com",
Description: "联系邮箱,用于客服和业务咨询",
},
{
Name: "max_upload_size",
Value: "10485760",
Description: "文件上传最大尺寸字节默认10MB",
},
{
Name: "default_user_role",
Value: "1",
Description: "新用户默认角色0=管理员1=普通用户",
},
{
Name: "session_timeout",
Value: "3600",
Description: "会话超时时间默认1小时",
},
{
Name: "maintenance_mode",
Value: "0",
Description: "系统开关0=开启系统1=关闭系统",
},
// ===== 页脚与备案相关默认项 =====
{
Name: "footer_text",
Value: "Copyright © 2025 凌动技术. All Rights Reserved.",
Description: "页脚展示的版权或说明信息",
},
{
Name: "icp_record",
Value: "京ICP备12345678号",
Description: "ICP备案号留空则不显示",
},
{
Name: "icp_record_link",
Value: "https://beian.miit.gov.cn",
Description: "工信部ICP备案查询链接留空则不显示",
},
{
Name: "psb_record",
Value: "京公网安备 11000002000001号",
Description: "公安备案号,留空则不显示",
},
{
Name: "psb_record_link",
Value: "https://www.beian.gov.cn/portal/registerSystemInfo?recordcode=11000002000001",
Description: "公安备案查询链接,留空则不显示",
},
{
Name: "card_batch_counter",
Value: "0",
Description: "卡密批次号计数器(用于记录上次生成批次号的序号,自增使用)",
},
}
// 逐个检查并创建不存在的设置项
for _, setting := range defaultSettings {
var count int64
if err := db.Model(&models.Settings{}).Where("name = ?", setting.Name).Count(&count).Error; err != nil {
return err
}
if count == 0 {
if err := db.Create(&setting).Error; err != nil {
logrus.WithError(err).WithField("name", setting.Name).Error("创建默认设置失败")
return err
}
logrus.WithField("name", setting.Name).WithField("value", setting.Value).Info("创建默认设置项")
}
}
logrus.Info("默认系统设置初始化完成")
return nil
}

54
database/seed_user.go Normal file
View File

@@ -0,0 +1,54 @@
package database
import (
"networkDev/models"
"networkDev/utils"
"github.com/sirupsen/logrus"
)
// SeedDefaultAdmin 初始化默认管理员账号
// - 如果用户名为 admin 的用户已存在,则跳过
// - 如不存在,则创建用户名为 admin、密码为 admin123以 bcrypt 哈希存储)、角色 Role=0 的管理员
// - 根据需求:默认 admin 用户的 ID 固定为 10000
func SeedDefaultAdmin() error {
db, err := GetDB()
if err != nil {
return err
}
// 检查是否存在用户名为 admin 的用户
var count int64
if dbErr := db.Model(&models.User{}).Where("username = ?", "admin").Count(&count).Error; dbErr != nil {
return dbErr
}
if count > 0 {
return nil
}
// 生成密码盐值
salt, err := utils.GenerateRandomSalt()
if err != nil {
return err
}
// 使用盐值生成密码哈希(不存明文)
hash, err := utils.HashPasswordWithSalt("admin123", salt)
if err != nil {
return err
}
// 创建默认管理员(指定固定 ID=10000
admin := models.User{
ID: 10000,
Username: "admin",
Password: hash,
PasswordSalt: salt,
Role: 0, // 0=管理员
}
if err := db.Create(&admin).Error; err != nil {
return err
}
logrus.WithField("username", "admin").WithField("id", admin.ID).Info("默认管理员创建成功")
return nil
}

48
go.mod Normal file
View File

@@ -0,0 +1,48 @@
module networkDev
go 1.24.1
require (
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/redis/go-redis/v9 v9.13.0
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.1
golang.org/x/crypto v0.41.0
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.30.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)

113
go.sum Normal file
View File

@@ -0,0 +1,113 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.13.0 h1:PpmlVykE0ODh8P43U0HqC+2NXHXwG+GUtQyz+MPKGRg=
github.com/redis/go-redis/v9 v9.13.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4=
gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=

9
main.go Normal file
View File

@@ -0,0 +1,9 @@
package main
import "networkDev/cmd"
// main 是程序的入口点
// 调用Cobra命令执行器来处理命令行参数和子命令
func main() {
cmd.Execute()
}

133
middleware/middleware.go Normal file
View File

@@ -0,0 +1,133 @@
package middleware
import (
"net/http"
"strings"
"time"
"networkDev/utils/logger"
)
// LoggingMiddleware HTTP请求日志中间件
// 记录每个HTTP请求的详细信息包括方法、路径、状态码和响应时间
type LoggingMiddleware struct {
logger *logger.Logger
}
// NewLoggingMiddleware 创建新的日志中间件实例
func NewLoggingMiddleware(logger *logger.Logger) *LoggingMiddleware {
return &LoggingMiddleware{
logger: logger,
}
}
// responseWriter 包装http.ResponseWriter以捕获状态码
type responseWriter struct {
http.ResponseWriter
statusCode int
written bool
}
// newResponseWriter 创建新的响应写入器包装器
func newResponseWriter(w http.ResponseWriter) *responseWriter {
return &responseWriter{
ResponseWriter: w,
statusCode: http.StatusOK, // 默认状态码
}
}
// WriteHeader 重写WriteHeader方法以捕获状态码
func (rw *responseWriter) WriteHeader(code int) {
if !rw.written {
rw.statusCode = code
rw.written = true
rw.ResponseWriter.WriteHeader(code)
}
}
// Write 重写Write方法以确保状态码被设置
func (rw *responseWriter) Write(b []byte) (int, error) {
if !rw.written {
rw.WriteHeader(http.StatusOK)
}
return rw.ResponseWriter.Write(b)
}
// Handler 中间件处理器函数
// 包装HTTP处理器以添加请求日志记录功能
func (lm *LoggingMiddleware) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// 包装响应写入器以捕获状态码
wrapped := newResponseWriter(w)
// 调用下一个处理器
next.ServeHTTP(wrapped, r)
// 计算响应时间
duration := time.Since(start)
// 记录请求日志
lm.logger.LogRequestWithHeaders(
r.Method,
r.URL.Path,
getClientIP(r),
wrapped.statusCode,
duration,
"-",
r.Header.Get("User-Agent"),
)
})
}
// getClientIP 获取客户端真实IP地址
// 优先从X-Forwarded-For、X-Real-IP等头部获取最后使用RemoteAddr
func getClientIP(r *http.Request) string {
// 检查X-Forwarded-For头部
xForwardedFor := r.Header.Get("X-Forwarded-For")
if xForwardedFor != "" {
// X-Forwarded-For可能包含多个IP取第一个
ips := strings.Split(xForwardedFor, ",")
if len(ips) > 0 {
return strings.TrimSpace(ips[0])
}
}
// 检查X-Real-IP头部
xRealIP := r.Header.Get("X-Real-IP")
if xRealIP != "" {
return xRealIP
}
// 检查X-Forwarded头部
xForwarded := r.Header.Get("X-Forwarded")
if xForwarded != "" {
return xForwarded
}
// 使用RemoteAddr
remoteAddr := r.RemoteAddr
if strings.Contains(remoteAddr, ":") {
// 移除端口号
if idx := strings.LastIndex(remoteAddr, ":"); idx != -1 {
return remoteAddr[:idx]
}
}
return remoteAddr
}
// WrapHandler 包装HTTP处理器以添加日志记录功能
// 使用全局日志记录器创建日志中间件
func WrapHandler(handler http.Handler) http.Handler {
logger := logger.GetLogger()
middleware := NewLoggingMiddleware(logger)
return middleware.Handler(handler)
}
// WrapHandlerFunc 包装HTTP处理器函数以添加日志记录功能
// 将HandlerFunc转换为Handler并添加日志中间件
func WrapHandlerFunc(handlerFunc http.HandlerFunc) http.Handler {
return WrapHandler(handlerFunc)
}

97
models/api.go Normal file
View File

@@ -0,0 +1,97 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// API 接口表模型
// 用于管理API接口的配置信息
// 包含加密算法配置、密钥管理等功能
// 支持多种加密算法不加密、RC4、RSA、RSA动态
type API struct {
// ID主键自增
ID uint `gorm:"primaryKey;comment:API接口ID自增主键" json:"id"`
// API类型int型
APIType int `gorm:"not null;comment:API类型" json:"api_type"`
// API密钥
APIKey string `gorm:"size:255;not null;uniqueIndex;comment:API密钥唯一标识" json:"api_key"`
// 应用UUID关联到App表
AppUUID string `gorm:"size:36;not null;index;comment:关联的应用UUID" json:"app_uuid"`
// 接口状态1=启用0=禁用)
Status int `gorm:"default:1;not null;comment:接口状态1=启用0=禁用" json:"status"`
// 接口提交算法
// 支持的算法0=不加密1=RC42=RSA3=RSA动态
SubmitAlgorithm int `gorm:"default:0;not null;comment:提交算法0=不加密1=RC42=RSA3=RSA动态" json:"submit_algorithm"`
// 接口返回算法
// 支持的算法0=不加密1=RC42=RSA3=RSA动态
ReturnAlgorithm int `gorm:"default:0;not null;comment:返回算法0=不加密1=RC42=RSA3=RSA动态" json:"return_algorithm"`
// 提交算法公钥base64编码存储
SubmitPublicKey string `gorm:"type:text;comment:提交算法公钥base64编码" json:"submit_public_key"`
// 提交算法私钥base64编码存储
SubmitPrivateKey string `gorm:"type:text;comment:提交算法私钥base64编码" json:"submit_private_key"`
// 返回算法公钥base64编码存储
ReturnPublicKey string `gorm:"type:text;comment:返回算法公钥base64编码" json:"return_public_key"`
// 返回算法私钥base64编码存储
ReturnPrivateKey string `gorm:"type:text;comment:返回算法私钥base64编码" json:"return_private_key"`
// 时间字段
CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"`
}
// BeforeCreate 在创建记录前自动生成API密钥
func (api *API) BeforeCreate(tx *gorm.DB) error {
if api.APIKey == "" {
// 生成唯一的API密钥
api.APIKey = "api_" + uuid.New().String()
}
return nil
}
// TableName 指定表名
func (API) TableName() string {
return "apis"
}
// 算法类型常量
const (
AlgorithmNone = 0 // 不加密
AlgorithmRC4 = 1 // RC4
AlgorithmRSA = 2 // RSA
AlgorithmRSADynamic = 3 // RSA动态
)
// GetAlgorithmName 获取算法名称
func GetAlgorithmName(algorithm int) string {
switch algorithm {
case AlgorithmNone:
return "不加密"
case AlgorithmRC4:
return "RC4"
case AlgorithmRSA:
return "RSA"
case AlgorithmRSADynamic:
return "RSA动态"
default:
return "未知算法"
}
}
// IsValidAlgorithm 验证算法类型是否有效
func IsValidAlgorithm(algorithm int) bool {
return algorithm >= AlgorithmNone && algorithm <= AlgorithmRSADynamic
}

63
models/app.go Normal file
View File

@@ -0,0 +1,63 @@
package models
import (
"crypto/rand"
"encoding/hex"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// App 应用表模型
// 用于管理应用程序的基本信息
// UUID 为应用的唯一标识符,自动生成
// Status 为应用状态1:启用 0:禁用默认为1
// Name 为应用名称
// Secret 为应用密钥用于API认证
// Version 为应用版本号
// CreatedAt/UpdatedAt 由 GORM 自动维护
type App struct {
// ID主键自增同时通过 json 标签保证前端接收为 id
ID uint `gorm:"primaryKey;comment:应用ID自增主键" json:"id"`
// UUID应用唯一标识符自动生成
UUID string `gorm:"uniqueIndex;size:36;not null;comment:应用UUID唯一标识符" json:"uuid"`
// Status状态1=启用0=禁用json 名称与前端一致
Status int `gorm:"default:1;not null;comment:应用状态1=启用0=禁用" json:"status"`
// Name应用名称json 名称与前端一致
Name string `gorm:"size:100;not null;comment:应用名称" json:"name"`
// Secret应用密钥用于API认证
Secret string `gorm:"size:255;not null;comment:应用密钥用于API认证" json:"secret"`
// Version应用版本号
Version string `gorm:"size:50;default:'1.0.0';comment:应用版本号" json:"version"`
// ForceUpdate强制更新0=不开启1=开启)
ForceUpdate int `gorm:"default:0;not null;comment:强制更新0=不开启1=开启" json:"force_update"`
// DownloadType下载方式0=不启用更新1=自动更新2=手动下载)
DownloadType int `gorm:"default:0;not null;comment:更新方式0=不启用更新1=自动更新2=手动下载" json:"download_type"`
// DownloadURL下载地址
DownloadURL string `gorm:"size:500;comment:下载地址" json:"download_url"`
// CreatedAt/UpdatedAt时间字段返回为 created_at/updated_at便于前端展示
CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"`
}
// BeforeCreate 在创建记录前自动生成UUID和密钥
func (app *App) BeforeCreate(tx *gorm.DB) error {
if app.UUID == "" {
app.UUID = uuid.New().String()
}
if app.Secret == "" {
// 生成32位大写16进制随机字符
bytes := make([]byte, 16) // 16字节 = 32位16进制字符
rand.Read(bytes)
app.Secret = strings.ToUpper(hex.EncodeToString(bytes))
}
return nil
}
// TableName 指定表名
func (App) TableName() string {
return "apps"
}

27
models/card.go Normal file
View File

@@ -0,0 +1,27 @@
package models
import (
"time"
)
// Card 卡密模型
// 用于存储和管理系统中的卡密信息,包括卡密号码、状态、使用情况等
type Card struct {
// ID主键自增
ID uint `gorm:"primaryKey;comment:卡密ID自增主键" json:"id"`
// CardNumber卡密号码唯一且非空
CardNumber string `gorm:"size:200;not null;comment:卡密号码(十六进制字符串)" json:"card_number"`
// CardTypeID所属卡密类型ID外键
CardTypeID uint `gorm:"not null;index;comment:所属卡密类型ID外键" json:"card_type_id"`
// Status状态0=未使用1=已使用2=禁用)
Status int `gorm:"default:0;not null;comment:状态0=未使用1=已使用2=禁用" json:"status"`
// Batch批次标识用于区分导入或生成批次
Batch string `gorm:"size:100;comment:批次标识" json:"batch"`
// Remark备注信息
Remark string `gorm:"size:255;comment:备注信息" json:"remark"`
// UsedAt使用时间未使用为NULL调整到创建时间前面以便前端展示顺序一致
UsedAt *time.Time `gorm:"comment:使用时间" json:"used_at"`
// CreatedAt/UpdatedAt时间字段
CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"`
}

24
models/card_type.go Normal file
View File

@@ -0,0 +1,24 @@
package models
import "time"
// CardType 卡密类型表模型
// 用于管理不同类型的卡密ChatGPT、Claude、Suno、Grok等
// ID 为自增主键
// Name 为卡密类型名称,唯一索引
// Status 为状态1:启用 0:禁用默认为1
// CreatedAt/UpdatedAt 由 GORM 自动维护
type CardType struct {
// ID主键自增同时通过 json 标签保证前端接收为 id
ID uint `gorm:"primaryKey;comment:卡密类型ID自增主键" json:"id"`
// Name名称唯一json 名称与前端一致
Name string `gorm:"uniqueIndex;size:100;not null;comment:卡密类型名称,唯一索引" json:"name"`
// Status状态1=启用0=禁用json 名称与前端一致
Status int `gorm:"default:1;not null;comment:状态1=启用0=禁用" json:"status"`
// LoginTypes登录方式逗号分隔json 使用 login_types
LoginTypes string `gorm:"type:varchar(500);default:'';comment:登录方式,多个用逗号分隔" json:"login_types"`
// CreatedAt/UpdatedAt时间字段返回为 created_at/updated_at便于前端展示
CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"`
}

24
models/login_type.go Normal file
View File

@@ -0,0 +1,24 @@
package models
import "time"
// LoginType 登录类型表模型
// 用于管理不同的登录方式如直登、Google、Microsoft、Apple等
// ID 为自增主键
// Name 为登录类型名称,唯一索引
// Status 为状态1:启用 0:禁用默认为1
// CreatedAt/UpdatedAt 由 GORM 自动维护
type LoginType struct {
// ID主键自增同时通过 json 标签保证前端接收为 id
ID uint `gorm:"primaryKey;comment:登录类型ID自增主键" json:"id"`
// Name名称唯一json 名称与前端一致
Name string `gorm:"uniqueIndex;size:100;not null;comment:登录类型名称,唯一索引" json:"name"`
// Status状态1=启用0=禁用json 名称与前端一致
Status int `gorm:"default:1;not null;comment:状态1=启用0=禁用" json:"status"`
// VerifyTypes验证方式逗号分隔json 使用 verify_types用于记录多种验证方式输入内容用多个用逗号分隔
VerifyTypes string `gorm:"type:varchar(500);default:'';comment:验证方式,输入内容用多个用逗号分隔" json:"verify_types"`
// CreatedAt/UpdatedAt时间字段返回为 created_at/updated_at便于前端展示
CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"`
}

19
models/settings.go Normal file
View File

@@ -0,0 +1,19 @@
package models
import "time"
// Settings 系统设置表模型
// 用于存储应用的配置参数
// Name 为配置项名称,唯一索引
// Value 为配置项的值
// Description 为配置项描述说明
// CreatedAt/UpdatedAt 由 GORM 自动维护
type Settings struct {
ID uint `gorm:"primaryKey;comment:设置ID自增主键"`
Name string `gorm:"uniqueIndex;size:64;not null;comment:配置项名称,唯一索引"`
Value string `gorm:"type:text;comment:配置项的值"`
Description string `gorm:"size:255;comment:配置项描述说明"`
CreatedAt time.Time `gorm:"comment:创建时间"`
UpdatedAt time.Time `gorm:"comment:更新时间"`
}

16
models/user.go Normal file
View File

@@ -0,0 +1,16 @@
package models
import "time"
// User 用户表模型
// 说明PasswordSalt 使用 32 字节随机盐(以 16 进制存储为 64 个字符),因此列长度设置为 64
type User struct {
ID uint `gorm:"primaryKey;comment:用户ID自增主键"`
Username string `gorm:"uniqueIndex;size:64;not null;comment:用户名,唯一索引"`
Password string `gorm:"size:255;not null;comment:密码哈希值"`
PasswordSalt string `gorm:"size:64;not null;comment:密码加密盐值"`
Role int `gorm:"not null;comment:用户角色0=管理员1=普通用户"`
CreatedAt time.Time `gorm:"comment:创建时间"`
UpdatedAt time.Time `gorm:"comment:更新时间"`
}

99
server/admin.go Normal file
View File

@@ -0,0 +1,99 @@
package server
import (
"net/http"
adminctl "networkDev/controllers/admin"
)
// RegisterAdminRoutes 注册管理员后台相关路由
// - /admin/login: 支持GET渲染登录页、POST提交登录
// - /admin/logout: 管理员退出登录
// - /admin/dashboard: 管理员仪表盘(示例)
// - /admin/fragment/*: 布局内动态片段加载
// - /admin/api/settings*: 设置接口(查询/更新)
func RegisterAdminRoutes(mux *http.ServeMux) {
// /admin 根与前缀统一入口:根据是否登录跳转
mux.HandleFunc("/admin", adminctl.AdminIndexHandler)
mux.HandleFunc("/admin/", adminctl.AdminIndexHandler)
// Admin 认证相关路由
mux.HandleFunc("/admin/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
adminctl.LoginPageHandler(w, r)
return
}
if r.Method == http.MethodPost {
adminctl.LoginHandler(w, r)
return
}
w.WriteHeader(http.StatusMethodNotAllowed)
})
// 退出登录(无需拦截,幂等清理)
mux.HandleFunc("/admin/logout", adminctl.LogoutHandler)
// 后台布局页(需要管理员认证)
mux.HandleFunc("/admin/layout", adminctl.AdminAuthRequired(adminctl.AdminLayoutHandler))
// 片段路由(需要管理员认证)
mux.HandleFunc("/admin/dashboard", adminctl.AdminAuthRequired(adminctl.DashboardFragmentHandler))
mux.HandleFunc("/admin/user", adminctl.AdminAuthRequired(adminctl.UserFragmentHandler))
mux.HandleFunc("/admin/settings", adminctl.AdminAuthRequired(adminctl.SettingsFragmentHandler))
mux.HandleFunc("/admin/apps", adminctl.AdminAuthRequired(adminctl.AppsFragmentHandler))
mux.HandleFunc("/admin/logintypes", adminctl.AdminAuthRequired(adminctl.LoginTypesFragmentHandler))
mux.HandleFunc("/admin/cardtypes", adminctl.AdminAuthRequired(adminctl.CardTypesFragmentHandler))
mux.HandleFunc("/admin/cards", adminctl.AdminAuthRequired(adminctl.CardsFragmentHandler))
// 个人资料API
mux.HandleFunc("/admin/api/user/profile", adminctl.AdminAuthRequired(adminctl.UserProfileQueryHandler))
mux.HandleFunc("/admin/api/user/profile/update", adminctl.AdminAuthRequired(adminctl.UserProfileUpdateHandler))
mux.HandleFunc("/admin/api/user/password", adminctl.AdminAuthRequired(adminctl.UserPasswordUpdateHandler))
// 设置API需要管理员认证
mux.HandleFunc("/admin/api/settings", adminctl.AdminAuthRequired(adminctl.SettingsQueryHandler))
mux.HandleFunc("/admin/api/settings/update", adminctl.AdminAuthRequired(adminctl.SettingsUpdateHandler))
// 供前端下拉选择卡密类型
mux.HandleFunc("/admin/api/cards/types", adminctl.AdminAuthRequired(adminctl.GetCardTypesHandler))
// 应用管理API
mux.HandleFunc("/admin/api/apps/list", adminctl.AdminAuthRequired(adminctl.AppsListHandler))
mux.HandleFunc("/admin/api/apps/create", adminctl.AdminAuthRequired(adminctl.AppCreateHandler))
mux.HandleFunc("/admin/api/apps/update", adminctl.AdminAuthRequired(adminctl.AppUpdateHandler))
mux.HandleFunc("/admin/api/apps/delete", adminctl.AdminAuthRequired(adminctl.AppDeleteHandler))
mux.HandleFunc("/admin/api/apps/batch_delete", adminctl.AdminAuthRequired(adminctl.AppsBatchDeleteHandler))
mux.HandleFunc("/admin/api/apps/batch_update_status", adminctl.AdminAuthRequired(adminctl.AppsBatchUpdateStatusHandler))
// 登录方式管理API
mux.HandleFunc("/admin/api/login_types/list", adminctl.AdminAuthRequired(adminctl.LoginTypesListHandler))
mux.HandleFunc("/admin/api/login_types/create", adminctl.AdminAuthRequired(adminctl.LoginTypeCreateHandler))
mux.HandleFunc("/admin/api/login_types/update", adminctl.AdminAuthRequired(adminctl.LoginTypeUpdateHandler))
mux.HandleFunc("/admin/api/login_types/delete", adminctl.AdminAuthRequired(adminctl.LoginTypeDeleteHandler))
mux.HandleFunc("/admin/api/login_types/batch_delete", adminctl.AdminAuthRequired(adminctl.LoginTypesBatchDeleteHandler))
mux.HandleFunc("/admin/api/login_types/batch_enable", adminctl.AdminAuthRequired(adminctl.LoginTypesBatchEnableHandler))
mux.HandleFunc("/admin/api/login_types/batch_disable", adminctl.AdminAuthRequired(adminctl.LoginTypesBatchDisableHandler))
// 卡密类型管理API
mux.HandleFunc("/admin/api/card_types/list", adminctl.AdminAuthRequired(adminctl.CardTypesListHandler))
mux.HandleFunc("/admin/api/card_types/create", adminctl.AdminAuthRequired(adminctl.CardTypeCreateHandler))
mux.HandleFunc("/admin/api/card_types/update", adminctl.AdminAuthRequired(adminctl.CardTypeUpdateHandler))
mux.HandleFunc("/admin/api/card_types/delete", adminctl.AdminAuthRequired(adminctl.CardTypeDeleteHandler))
mux.HandleFunc("/admin/api/card_types/batch_delete", adminctl.AdminAuthRequired(adminctl.CardTypesBatchDeleteHandler))
mux.HandleFunc("/admin/api/card_types/batch_enable", adminctl.AdminAuthRequired(adminctl.CardTypesBatchEnableHandler))
mux.HandleFunc("/admin/api/card_types/batch_disable", adminctl.AdminAuthRequired(adminctl.CardTypesBatchDisableHandler))
// 卡密管理API
mux.HandleFunc("/admin/api/cards/list", adminctl.AdminAuthRequired(adminctl.CardsListHandler))
mux.HandleFunc("/admin/api/cards/create", adminctl.AdminAuthRequired(adminctl.CardCreateHandler))
mux.HandleFunc("/admin/api/cards/update", adminctl.AdminAuthRequired(adminctl.CardUpdateHandler))
mux.HandleFunc("/admin/api/cards/delete", adminctl.AdminAuthRequired(adminctl.CardDeleteHandler))
mux.HandleFunc("/admin/api/cards/batch_delete", adminctl.AdminAuthRequired(adminctl.CardsBatchDeleteHandler))
mux.HandleFunc("/admin/api/cards/batch_update_status", adminctl.AdminAuthRequired(adminctl.CardsBatchUpdateStatusHandler))
// 新增卡密导出APICSV下载
mux.HandleFunc("/admin/api/cards/export", adminctl.AdminAuthRequired(adminctl.CardsExportHandler))
// 新增导出选中卡密API
mux.HandleFunc("/admin/api/cards/export_selected", adminctl.AdminAuthRequired(adminctl.CardsExportSelectedHandler))
// 系统信息API用于仪表盘定时刷新
mux.HandleFunc("/admin/api/system/info", adminctl.AdminAuthRequired(adminctl.SystemInfoHandler))
// 卡密统计API用于仪表盘统计显示
mux.HandleFunc("/admin/api/cards/stats_overview", adminctl.AdminAuthRequired(adminctl.CardStatsOverviewHandler))
mux.HandleFunc("/admin/api/cards/trend_30days", adminctl.AdminAuthRequired(adminctl.CardStatsTrend30DaysHandler))
mux.HandleFunc("/admin/api/cards/stats_simple", adminctl.AdminAuthRequired(adminctl.CardStatsSimpleHandler))
}

13
server/home.go Normal file
View File

@@ -0,0 +1,13 @@
package server
import (
"net/http"
"networkDev/controllers/home"
)
// RegisterHomeRoutes 注册主页路由
// 只包含根路径,用于主页功能
func RegisterHomeRoutes(mux *http.ServeMux) {
// 根路径 - 主页
mux.HandleFunc("/", home.RootHandler)
}

37
server/routes.go Normal file
View File

@@ -0,0 +1,37 @@
package server
import (
"io/fs"
"log"
"net/http"
"networkDev/web"
)
// RegisterRoutes 聚合注册所有路由
func RegisterRoutes(mux *http.ServeMux) {
registerStaticRoutes(mux)
RegisterHomeRoutes(mux)
RegisterAdminRoutes(mux)
}
// registerStaticRoutes 注册静态资源路由
// 静态资源服务,将 /static/ 和 /assets/ 映射到嵌入的文件系统
func registerStaticRoutes(mux *http.ServeMux) {
if fsys, err := web.GetStaticFS(); err == nil {
// 为 /static/ 路径创建子文件系统
if staticSubFS, staticErr := fs.Sub(fsys, "static"); staticErr == nil {
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSubFS))))
} else {
log.Printf("创建静态资源子文件系统失败: %v", staticErr)
}
// 为 /assets/ 路径创建子文件系统
if assetsSubFS, assetsErr := fs.Sub(fsys, "assets"); assetsErr == nil {
mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(assetsSubFS))))
} else {
log.Printf("创建资产资源子文件系统失败: %v", assetsErr)
}
} else {
log.Printf("初始化静态资源文件系统失败: %v", err)
}
}

121
services/query.go Normal file
View File

@@ -0,0 +1,121 @@
package services
import (
"context"
"fmt"
"networkDev/models"
"networkDev/utils"
"time"
"gorm.io/gorm"
)
// FindCardByCardNumber 根据卡号查找卡密
// cardNumber: 卡号
// db: 数据库连接
// 返回: 卡密信息和错误
func FindCardByCardNumber(cardNumber string, db *gorm.DB) (*models.Card, error) {
key := fmt.Sprintf("card:number:%s", cardNumber)
return utils.RedisGetOrSet(context.Background(), key, 60*time.Second, func() (*models.Card, error) {
var card models.Card
err := db.Where("card_number = ?", cardNumber).First(&card).Error
if err != nil {
return nil, err
}
return &card, nil
})
}
// FindCardTypeByID 根据ID查找卡密类型
// id: 卡密类型ID
// db: 数据库连接
// 返回: 卡密类型信息和错误
func FindCardTypeByID(id uint, db *gorm.DB) (*models.CardType, error) {
key := fmt.Sprintf("card_type:id:%d", id)
return utils.RedisGetOrSet(context.Background(), key, 30*time.Minute, func() (*models.CardType, error) {
var cardType models.CardType
err := db.Where("id = ?", id).First(&cardType).Error
if err != nil {
return nil, err
}
return &cardType, nil
})
}
// FindSettingByName 根据名称查找设置
// name: 设置名称
// db: 数据库连接
// 返回: 设置信息和错误
func FindSettingByName(name string, db *gorm.DB) (*models.Settings, error) {
key := fmt.Sprintf("setting:%s", name)
return utils.RedisGetOrSet(context.Background(), key, 5*time.Minute, func() (*models.Settings, error) {
var setting models.Settings
err := db.Where("name = ?", name).First(&setting).Error
if err != nil {
return nil, err
}
return &setting, nil
})
}
// UpdateEntityByID 根据ID更新实体
// model: 模型类型
// id: 实体ID
// updates: 更新字段
// db: 数据库连接
// 返回: 错误
func UpdateEntityByID(model interface{}, id uint, updates map[string]interface{}, db *gorm.DB) error {
return db.Model(model).Where("id = ?", id).Updates(updates).Error
}
// BatchUpdateEntityStatus 批量更新实体状态
// model: 模型类型
// ids: 实体ID列表
// status: 新状态
// db: 数据库连接
// 返回: 错误
func BatchUpdateEntityStatus(model interface{}, ids []uint, status int, db *gorm.DB) error {
if len(ids) == 0 {
return nil
}
return db.Model(model).Where("id IN ?", ids).Update("status", status).Error
}
// CountEntitiesByCondition 根据条件统计实体数量
// model: 模型类型
// condition: 查询条件
// db: 数据库连接
// args: 查询参数
// 返回: 数量和错误
func CountEntitiesByCondition(model interface{}, condition string, db *gorm.DB, args ...interface{}) (int64, error) {
var count int64
err := db.Model(model).Where(condition, args...).Count(&count).Error
return count, err
}
// FindEntitiesByCondition 根据条件查找实体
// model: 模型类型
// result: 结果容器
// condition: 查询条件
// db: 数据库连接
// args: 查询参数
// 返回: 错误
func FindEntitiesByCondition(model interface{}, result interface{}, condition string, db *gorm.DB, args ...interface{}) error {
return db.Model(model).Where(condition, args...).Find(result).Error
}
// CheckEntityExists 检查实体是否存在
// model: 模型类型
// condition: 查询条件
// db: 数据库连接
// args: 查询参数
// 返回: 是否存在和错误
func CheckEntityExists(model interface{}, condition string, db *gorm.DB, args ...interface{}) (bool, error) {
var count int64
err := db.Model(model).Where(condition, args...).Count(&count).Error
return count > 0, err
}

105
services/settings.go Normal file
View File

@@ -0,0 +1,105 @@
package services
import (
"networkDev/database"
"networkDev/models"
"strconv"
"sync"
"github.com/sirupsen/logrus"
)
// SettingsService 设置服务
type SettingsService struct {
mu sync.RWMutex
cache map[string]string
}
var settingsService *SettingsService
var settingsOnce sync.Once
// GetSettingsService 获取设置服务单例
func GetSettingsService() *SettingsService {
settingsOnce.Do(func() {
settingsService = &SettingsService{
cache: make(map[string]string),
}
// 初始化时加载所有设置
settingsService.loadAllSettings()
})
return settingsService
}
// loadAllSettings 从数据库加载所有设置到缓存
func (s *SettingsService) loadAllSettings() {
db, err := database.GetDB()
if err != nil {
logrus.WithError(err).Error("获取数据库连接失败")
return
}
var settings []models.Settings
if err := db.Find(&settings).Error; err != nil {
logrus.WithError(err).Error("加载设置失败")
return
}
s.mu.Lock()
defer s.mu.Unlock()
for _, setting := range settings {
s.cache[setting.Name] = setting.Value
}
logrus.WithField("count", len(settings)).Info("设置缓存加载完成")
}
// GetString 获取字符串类型的设置值
func (s *SettingsService) GetString(name, defaultValue string) string {
s.mu.RLock()
defer s.mu.RUnlock()
if value, exists := s.cache[name]; exists {
return value
}
return defaultValue
}
// GetInt 获取整数类型的设置值
func (s *SettingsService) GetInt(name string, defaultValue int) int {
strValue := s.GetString(name, "")
if strValue == "" {
return defaultValue
}
if intValue, err := strconv.Atoi(strValue); err == nil {
return intValue
}
return defaultValue
}
// GetBool 获取布尔类型的设置值
func (s *SettingsService) GetBool(name string, defaultValue bool) bool {
strValue := s.GetString(name, "")
if strValue == "" {
return defaultValue
}
return strValue == "1" || strValue == "true"
}
// RefreshCache 刷新设置缓存
func (s *SettingsService) RefreshCache() {
s.loadAllSettings()
}
// GetSessionTimeout 获取会话超时时间(秒)
func (s *SettingsService) GetSessionTimeout() int {
return s.GetInt("session_timeout", 3600) // 默认1小时
}
// IsMaintenanceMode 检查系统是否关闭
func (s *SettingsService) IsMaintenanceMode() bool {
return s.GetBool("maintenance_mode", false)
}

80
utils/common.go Normal file
View File

@@ -0,0 +1,80 @@
package utils
import (
"encoding/json"
"net/http"
"networkDev/web"
"strings"
)
// JsonResponse 通用JSON响应函数
// 将 success 转换为 codetrue -> 0, false -> 1并输出 data
func JsonResponse(w http.ResponseWriter, status int, success bool, message string, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
// 将success转换为code格式true -> 0, false -> 1
code := 1
if success {
code = 0
}
json.NewEncoder(w).Encode(map[string]interface{}{
"code": code,
"msg": message,
"data": data,
})
}
// RenderTemplate 通用模板渲染函数
// templateName: 模板文件名
// data: 模板数据
// w: HTTP响应写入器
func RenderTemplate(w http.ResponseWriter, templateName string, data map[string]interface{}) error {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl, err := web.ParseTemplates()
if err != nil {
http.Error(w, "模板解析失败", http.StatusInternalServerError)
return err
}
if err := tmpl.ExecuteTemplate(w, templateName, data); err != nil {
http.Error(w, "模板渲染失败", http.StatusInternalServerError)
return err
}
return nil
}
// GetDefaultTemplateData 获取默认模板数据
// 返回包含系统基础信息的数据映射
func GetDefaultTemplateData() map[string]interface{} {
return map[string]interface{}{
"SystemName": "网络验证系统",
"FooterText": "© 2025 凌动技术 保留所有权利",
}
}
// GetClientIP 获取客户端IP地址
// 优先从 X-Forwarded-For 和 X-Real-IP 头部获取,否则使用 RemoteAddr
func GetClientIP(r *http.Request) string {
// 检查 X-Forwarded-For 头部
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// X-Forwarded-For 可能包含多个IP取第一个
if idx := strings.Index(xff, ","); idx != -1 {
return strings.TrimSpace(xff[:idx])
}
return strings.TrimSpace(xff)
}
// 检查 X-Real-IP 头部
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return strings.TrimSpace(xri)
}
// 使用 RemoteAddr
if idx := strings.LastIndex(r.RemoteAddr, ":"); idx != -1 {
return r.RemoteAddr[:idx]
}
return r.RemoteAddr
}

323
utils/crypto.go Normal file
View File

@@ -0,0 +1,323 @@
package utils
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
"sync"
"github.com/spf13/viper"
"golang.org/x/crypto/bcrypt"
)
// CryptoManager 加密管理器,提供高性能的加密解密服务
type CryptoManager struct {
key []byte
gcm cipher.AEAD
mutex sync.RWMutex
inited bool
}
// 全局加密管理器实例
var cryptoManager = &CryptoManager{}
// initCrypto 初始化加密管理器
// 缓存密钥和GCM实例避免重复创建
func (cm *CryptoManager) initCrypto() error {
cm.mutex.Lock()
defer cm.mutex.Unlock()
if cm.inited {
return nil
}
// 从配置中获取密钥
secret := viper.GetString("encryption_key")
if secret == "" {
secret = "default-secret"
}
// 生成AES密钥
sum := sha256.Sum256([]byte(secret))
cm.key = sum[:]
// 创建AES cipher
block, err := aes.NewCipher(cm.key)
if err != nil {
return err
}
// 创建GCM
gcm, err := cipher.NewGCM(block)
if err != nil {
return err
}
cm.gcm = gcm
cm.inited = true
return nil
}
// EncryptString 字符串加密AES-256-GCM
// 使用缓存的密钥和GCM实例提高性能
func EncryptString(plain string) (string, error) {
if err := cryptoManager.initCrypto(); err != nil {
return "", err
}
cryptoManager.mutex.RLock()
gcm := cryptoManager.gcm
cryptoManager.mutex.RUnlock()
// 生成随机nonce
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
// 加密
ciphertext := gcm.Seal(nil, nonce, []byte(plain), nil)
buf := append(nonce, ciphertext...)
return base64.StdEncoding.EncodeToString(buf), nil
}
// DecryptString 字符串解密AES-256-GCM
// 使用缓存的密钥和GCM实例提高性能
func DecryptString(enc string) (string, error) {
if err := cryptoManager.initCrypto(); err != nil {
return "", err
}
cryptoManager.mutex.RLock()
gcm := cryptoManager.gcm
cryptoManager.mutex.RUnlock()
// 解码base64
data, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
return "", err
}
// 检查数据长度
if len(data) < gcm.NonceSize() {
return "", errors.New("ciphertext too short")
}
// 分离nonce和密文
nonce := data[:gcm.NonceSize()]
ciphertext := data[gcm.NonceSize():]
// 解密
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plain), nil
}
// ResetCrypto 重置加密管理器(用于配置更新后重新初始化)
func ResetCrypto() {
cryptoManager.mutex.Lock()
defer cryptoManager.mutex.Unlock()
cryptoManager.inited = false
cryptoManager.key = nil
cryptoManager.gcm = nil
}
// EncryptStringBatch 批量加密字符串
// 减少锁竞争,提高批量处理性能
func EncryptStringBatch(plains []string) ([]string, error) {
if err := cryptoManager.initCrypto(); err != nil {
return nil, err
}
cryptoManager.mutex.RLock()
gcm := cryptoManager.gcm
cryptoManager.mutex.RUnlock()
results := make([]string, len(plains))
for i, plain := range plains {
// 生成随机nonce
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
// 加密
ciphertext := gcm.Seal(nil, nonce, []byte(plain), nil)
buf := append(nonce, ciphertext...)
results[i] = base64.StdEncoding.EncodeToString(buf)
}
return results, nil
}
// DecryptStringBatch 批量解密字符串
// 减少锁竞争,提高批量处理性能
func DecryptStringBatch(encs []string) ([]string, error) {
if err := cryptoManager.initCrypto(); err != nil {
return nil, err
}
cryptoManager.mutex.RLock()
gcm := cryptoManager.gcm
cryptoManager.mutex.RUnlock()
results := make([]string, len(encs))
for i, enc := range encs {
// 解码base64
data, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
return nil, err
}
// 检查数据长度
if len(data) < gcm.NonceSize() {
return nil, errors.New("ciphertext too short")
}
// 分离nonce和密文
nonce := data[:gcm.NonceSize()]
ciphertext := data[gcm.NonceSize():]
// 解密
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err
}
results[i] = string(plain)
}
return results, nil
}
// GenerateRandomSalt 生成随机密码盐值
// 生成32字节64个十六进制字符的随机盐值用于加密
// 返回: 十六进制格式的盐值字符串和错误信息
func GenerateRandomSalt() (string, error) {
length := 32 // 固定32字节
// 生成随机字节
bytes := make([]byte, length)
if _, err := io.ReadFull(rand.Reader, bytes); err != nil {
return "", err
}
// 转换为十六进制字符串
return fmt.Sprintf("%x", bytes), nil
}
// EncryptStringWithSalt 使用盐值进行字符串加密AES-256-GCM
// 将明文和盐值组合后进行加密,增强安全性
// plain: 待加密的明文字符串
// salt: 加密盐值
// 返回: base64编码的密文字符串和错误信息
func EncryptStringWithSalt(plain, salt string) (string, error) {
if err := cryptoManager.initCrypto(); err != nil {
return "", err
}
cryptoManager.mutex.RLock()
gcm := cryptoManager.gcm
cryptoManager.mutex.RUnlock()
// 将明文和盐值组合
combined := plain + salt
// 生成随机nonce
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
// 加密
ciphertext := gcm.Seal(nil, nonce, []byte(combined), nil)
buf := append(nonce, ciphertext...)
return base64.StdEncoding.EncodeToString(buf), nil
}
// DecryptStringWithSalt 使用盐值进行字符串解密AES-256-GCM
// 解密密文并移除盐值,返回原始明文
// enc: base64编码的密文字符串
// salt: 解密盐值
// 返回: 解密后的明文字符串和错误信息
func DecryptStringWithSalt(enc, salt string) (string, error) {
if err := cryptoManager.initCrypto(); err != nil {
return "", err
}
cryptoManager.mutex.RLock()
gcm := cryptoManager.gcm
cryptoManager.mutex.RUnlock()
// 解码base64
data, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
return "", err
}
// 检查数据长度
if len(data) < gcm.NonceSize() {
return "", errors.New("ciphertext too short")
}
// 分离nonce和密文
nonce := data[:gcm.NonceSize()]
ciphertext := data[gcm.NonceSize():]
// 解密
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
// 移除盐值,返回原始明文
combined := string(plain)
if len(combined) < len(salt) {
return "", errors.New("decrypted data too short")
}
// 验证盐值是否匹配
if combined[len(combined)-len(salt):] != salt {
return "", errors.New("salt mismatch")
}
return combined[:len(combined)-len(salt)], nil
}
// HashPasswordWithSalt 使用盐值对密码进行哈希处理
// 将密码和盐值组合后使用bcrypt进行哈希
// password: 原始密码
// salt: 密码盐值
// 返回: bcrypt哈希值和错误信息
func HashPasswordWithSalt(password, salt string) (string, error) {
// 将密码和盐值组合
combined := password + salt
// 使用bcrypt进行哈希成本因子12平衡安全性和性能
hashed, err := bcrypt.GenerateFromPassword([]byte(combined), 12)
if err != nil {
return "", err
}
return string(hashed), nil
}
// VerifyPasswordWithSalt 验证密码和盐值的组合是否匹配哈希值
// password: 原始密码
// salt: 密码盐值
// hashedPassword: 存储的哈希密码
// 返回: 验证结果true表示匹配
func VerifyPasswordWithSalt(password, salt, hashedPassword string) bool {
// 将密码和盐值组合
combined := password + salt
// 使用bcrypt验证
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(combined))
return err == nil
}

327
utils/database.go Normal file
View File

@@ -0,0 +1,327 @@
package utils
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/redis/go-redis/v9"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
"gorm.io/gorm"
)
// DatabaseConfig 数据库连接池配置结构体
// 用于配置数据库连接池的各项参数,包括连接池大小、生命周期管理和健康检查等
type DatabaseConfig struct {
// 连接池配置
MaxIdleConns int `mapstructure:"max_idle_conns"` // 最大空闲连接数
MaxOpenConns int `mapstructure:"max_open_conns"` // 最大打开连接数
ConnMaxLifetime time.Duration `mapstructure:"conn_max_lifetime"` // 连接最大生存时间
ConnMaxIdleTime time.Duration `mapstructure:"conn_max_idle_time"` // 连接最大空闲时间
// 健康检查配置
PingTimeout time.Duration `mapstructure:"ping_timeout"` // Ping超时时间
HealthCheckInterval time.Duration `mapstructure:"health_check_interval"` // 健康检查间隔
}
// GetDefaultDatabaseConfig 获取默认数据库配置
// 返回一个包含合理默认值的数据库配置实例
func GetDefaultDatabaseConfig() *DatabaseConfig {
return &DatabaseConfig{
MaxIdleConns: 10, // 默认最大空闲连接数
MaxOpenConns: 100, // 默认最大打开连接数
ConnMaxLifetime: 30 * time.Minute, // 连接最大生存时间30分钟
ConnMaxIdleTime: 10 * time.Minute, // 连接最大空闲时间10分钟
PingTimeout: 5 * time.Second, // Ping超时5秒
HealthCheckInterval: 30 * time.Second, // 健康检查间隔30秒
}
}
// LoadDatabaseConfig 从配置文件加载数据库配置
// 使用指定的前缀从viper配置中读取数据库配置如果配置项不存在则使用默认值
func LoadDatabaseConfig(prefix string) *DatabaseConfig {
config := GetDefaultDatabaseConfig()
// 从viper读取配置如果不存在则使用默认值
if viper.IsSet(prefix + ".max_idle_conns") {
config.MaxIdleConns = viper.GetInt(prefix + ".max_idle_conns")
}
if viper.IsSet(prefix + ".max_open_conns") {
config.MaxOpenConns = viper.GetInt(prefix + ".max_open_conns")
}
if viper.IsSet(prefix + ".conn_max_lifetime") {
config.ConnMaxLifetime = viper.GetDuration(prefix + ".conn_max_lifetime")
}
if viper.IsSet(prefix + ".conn_max_idle_time") {
config.ConnMaxIdleTime = viper.GetDuration(prefix + ".conn_max_idle_time")
}
if viper.IsSet(prefix + ".ping_timeout") {
config.PingTimeout = viper.GetDuration(prefix + ".ping_timeout")
}
if viper.IsSet(prefix + ".health_check_interval") {
config.HealthCheckInterval = viper.GetDuration(prefix + ".health_check_interval")
}
return config
}
// ConfigureConnectionPool 配置数据库连接池
// 根据提供的配置参数设置GORM数据库的连接池属性
func ConfigureConnectionPool(db *gorm.DB, config *DatabaseConfig) error {
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("获取底层数据库连接失败: %w", err)
}
// 设置连接池参数
sqlDB.SetMaxIdleConns(config.MaxIdleConns)
sqlDB.SetMaxOpenConns(config.MaxOpenConns)
sqlDB.SetConnMaxLifetime(config.ConnMaxLifetime)
sqlDB.SetConnMaxIdleTime(config.ConnMaxIdleTime)
// LogInfo("数据库连接池配置完成", map[string]interface{}{
// "max_idle_conns": config.MaxIdleConns,
// "max_open_conns": config.MaxOpenConns,
// "conn_max_lifetime": config.ConnMaxLifetime,
// "conn_max_idle_time": config.ConnMaxIdleTime,
// })
return nil
}
// PingDatabase 检查数据库连接健康状态
// 使用指定的超时时间ping数据库以验证连接是否正常
func PingDatabase(db *gorm.DB, timeout time.Duration) error {
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("获取底层数据库连接失败: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return sqlDB.PingContext(ctx)
}
// GetConnectionStats 获取数据库连接池统计信息
// 返回当前数据库连接池的详细统计数据,包括连接数、等待时间等
func GetConnectionStats(db *gorm.DB) (*sql.DBStats, error) {
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("获取底层数据库连接失败: %w", err)
}
stats := sqlDB.Stats()
return &stats, nil
}
// LogConnectionStats 记录数据库连接池统计信息
// 获取并记录数据库连接池的统计信息到日志中,用于监控和调试
func LogConnectionStats(db *gorm.DB) {
stats, err := GetConnectionStats(db)
if err != nil {
LogError("获取数据库连接池统计信息失败", err, nil)
return
}
LogInfo("数据库连接池统计", map[string]interface{}{
"open_connections": stats.OpenConnections,
"in_use": stats.InUse,
"idle": stats.Idle,
"wait_count": stats.WaitCount,
"wait_duration": stats.WaitDuration,
"max_idle_closed": stats.MaxIdleClosed,
"max_idle_time_closed": stats.MaxIdleTimeClosed,
"max_lifetime_closed": stats.MaxLifetimeClosed,
})
}
// StartHealthCheck 启动数据库健康检查
// 启动一个后台goroutine定期检查数据库连接健康状态
// 只在健康检查失败时输出错误日志,正常情况下不输出日志
func StartHealthCheck(db *gorm.DB, config *DatabaseConfig) {
go func() {
ticker := time.NewTicker(config.HealthCheckInterval)
defer ticker.Stop()
for range ticker.C {
if err := PingDatabase(db, config.PingTimeout); err != nil {
// 只在健康检查失败时输出错误日志
LogError("数据库健康检查失败", err, map[string]interface{}{
"ping_timeout": config.PingTimeout,
})
}
// 记录连接池统计信息(仅在调试模式下)
if logrus.GetLevel() == logrus.DebugLevel {
LogConnectionStats(db)
}
}
}()
// LogInfo("数据库健康检查已启动", map[string]interface{}{
// "check_interval": config.HealthCheckInterval,
// "ping_timeout": config.PingTimeout,
// })
}
// ValidateDatabaseConfig 验证数据库配置参数
// 检查数据库配置参数的有效性,确保所有参数都在合理范围内
func ValidateDatabaseConfig(config *DatabaseConfig) error {
if config.MaxIdleConns < 0 {
return fmt.Errorf("最大空闲连接数不能为负数: %d", config.MaxIdleConns)
}
if config.MaxOpenConns < 0 {
return fmt.Errorf("最大打开连接数不能为负数: %d", config.MaxOpenConns)
}
if config.MaxIdleConns > config.MaxOpenConns && config.MaxOpenConns > 0 {
return fmt.Errorf("最大空闲连接数(%d)不能大于最大打开连接数(%d)", config.MaxIdleConns, config.MaxOpenConns)
}
if config.ConnMaxLifetime < 0 {
return fmt.Errorf("连接最大生存时间不能为负数: %v", config.ConnMaxLifetime)
}
if config.ConnMaxIdleTime < 0 {
return fmt.Errorf("连接最大空闲时间不能为负数: %v", config.ConnMaxIdleTime)
}
if config.PingTimeout <= 0 {
return fmt.Errorf("Ping超时时间必须大于0: %v", config.PingTimeout)
}
if config.HealthCheckInterval <= 0 {
return fmt.Errorf("健康检查间隔必须大于0: %v", config.HealthCheckInterval)
}
return nil
}
var (
// redisClient 全局Redis客户端
redisClient *redis.Client
// redisOnce 确保只初始化一次
redisOnce sync.Once
// redisAvailable 标记Redis是否可用
redisAvailable bool
)
// InitRedis 初始化Redis客户端仅在配置存在时尝试连接
// - 从 viper 读取 security.redis.* 配置
// - 如果连接失败,则标记为不可用,不影响主流程
func InitRedis() {
redisOnce.Do(func() {
host := viper.GetString("redis.host")
port := viper.GetInt("redis.port")
if host == "" || port == 0 {
logrus.Info("未配置Redis或配置不完整跳过初始化")
redisAvailable = false
return
}
addr := fmt.Sprintf("%s:%d", host, port)
redisClient = redis.NewClient(&redis.Options{
Addr: addr,
Password: viper.GetString("redis.password"),
DB: viper.GetInt("redis.db"),
})
// 健康检查
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := redisClient.Ping(ctx).Err(); err != nil {
logrus.WithError(err).Warn("Redis初始化失败标记为不可用")
redisAvailable = false
return
}
redisAvailable = true
logrus.WithField("addr", addr).Info("Redis 连接已建立")
})
}
// GetRedis 获取全局Redis客户端可能返回nil当不可用时
func GetRedis() *redis.Client {
if redisClient == nil {
InitRedis()
}
if !redisAvailable {
return nil
}
return redisClient
}
// IsRedisAvailable 判断Redis是否可用
func IsRedisAvailable() bool {
if redisClient == nil {
InitRedis()
}
return redisAvailable
}
// RedisGetOrSet 通用Redis缓存获取或设置函数基于JSON序列化
// - ctx: 上下文
// - key: 缓存键
// - ttl: 过期时间
// - loader: 当缓存不存在时的加载函数(一般执行数据库查询)
// 返回:目标对象指针和错误
func RedisGetOrSet[T any](ctx context.Context, key string, ttl time.Duration, loader func() (*T, error)) (*T, error) {
// 如果Redis不可用则直接调用加载函数
if !IsRedisAvailable() {
return loader()
}
client := GetRedis()
if client == nil {
return loader()
}
// 先尝试从缓存读取
data, err := client.Get(ctx, key).Bytes()
if err == nil {
var out T
if uerr := json.Unmarshal(data, &out); uerr == nil {
return &out, nil
}
// 反序列化失败时视为未命中,继续加载
logrus.WithError(err).WithField("key", key).Warn("Redis缓存反序列化失败回退到loader")
} else if err != redis.Nil {
// 非空且非不存在的错误,记录告警但不中断
logrus.WithError(err).WithField("key", key).Warn("读取Redis缓存失败")
}
// 加载数据
val, lerr := loader()
if lerr != nil {
return nil, lerr
}
if val == nil {
return nil, nil
}
// 写回缓存(错误不影响主流程)
if b, merr := json.Marshal(val); merr == nil {
if serr := client.Set(ctx, key, b, ttl).Err(); serr != nil {
logrus.WithError(serr).WithField("key", key).Warn("写入Redis缓存失败")
}
}
return val, nil
}
// RedisDel 删除一个或多个Redis键当Redis不可用时静默返回
// - ctx: 上下文
// - keys: 需要删除的键名
func RedisDel(ctx context.Context, keys ...string) error {
// 如果Redis不可用则直接返回
if !IsRedisAvailable() {
return nil
}
client := GetRedis()
if client == nil {
return nil
}
if len(keys) == 0 {
return nil
}
if _, err := client.Del(ctx, keys...).Result(); err != nil {
logrus.WithError(err).WithField("keys", keys).Warn("删除Redis键失败")
return err
}
return nil
}

269
utils/errors.go Normal file
View File

@@ -0,0 +1,269 @@
package utils
import (
"encoding/json"
"fmt"
"log"
"net/http"
"runtime"
"time"
"gorm.io/gorm"
)
// ErrorResponse 统一的错误响应结构
// 用于标准化API错误响应格式
type ErrorResponse struct {
Success bool `json:"success"` // 请求是否成功错误响应时固定为false
Message string `json:"message"` // 错误消息描述
ErrorCode string `json:"error_code,omitempty"` // 错误代码,用于客户端识别错误类型
Data interface{} `json:"data"` // 附加数据,可为空
Timestamp int64 `json:"timestamp"` // 响应时间戳
}
// SuccessResponse 统一的成功响应结构
// 用于标准化API成功响应格式
type SuccessResponse struct {
Success bool `json:"success"` // 请求是否成功成功响应时固定为true
Message string `json:"message"` // 成功消息描述
Data interface{} `json:"data"` // 响应数据
Timestamp int64 `json:"timestamp"` // 响应时间戳
}
// ErrorCode 错误代码常量
// 定义标准化的错误代码,用于客户端识别和处理不同类型的错误
const (
ErrCodeInvalidRequest = "INVALID_REQUEST" // 无效请求,通常是请求参数格式错误
ErrCodeUnauthorized = "UNAUTHORIZED" // 未授权需要登录或token无效
ErrCodeForbidden = "FORBIDDEN" // 禁止访问,权限不足
ErrCodeNotFound = "NOT_FOUND" // 资源不存在
ErrCodeConflict = "CONFLICT" // 资源冲突,如重复创建
ErrCodeInternalError = "INTERNAL_ERROR" // 服务器内部错误
ErrCodeDatabaseError = "DATABASE_ERROR" // 数据库操作错误
ErrCodeValidationError = "VALIDATION_ERROR" // 数据验证错误
ErrCodeTokenExpired = "TOKEN_EXPIRED" // 令牌已过期
ErrCodeInsufficientData = "INSUFFICIENT_DATA" // 数据不足,缺少必要信息
)
// LogLevel 日志级别
// 定义不同的日志记录级别
type LogLevel int
const (
LogLevelInfo LogLevel = iota // 信息级别,记录一般信息
LogLevelWarn // 警告级别,记录潜在问题
LogLevelError // 错误级别,记录错误信息
LogLevelDebug // 调试级别,记录调试信息
)
// LogEntry 日志条目结构
// 包含完整的日志信息,用于结构化日志记录
type LogEntry struct {
Level LogLevel `json:"level"` // 日志级别
Message string `json:"message"` // 日志消息
Error string `json:"error,omitempty"` // 错误信息,仅在错误日志中存在
Context interface{} `json:"context,omitempty"` // 上下文信息,额外的结构化数据
Timestamp time.Time `json:"timestamp"` // 日志时间戳
File string `json:"file"` // 源文件路径
Line int `json:"line"` // 源文件行号
}
// WriteJSONResponse 写入JSON响应
// w: HTTP响应写入器
// statusCode: HTTP状态码
// response: 响应数据
func WriteJSONResponse(w http.ResponseWriter, statusCode int, response interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
if err := json.NewEncoder(w).Encode(response); err != nil {
LogError("Failed to encode JSON response", err, nil)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
// WriteErrorResponse 写入错误响应
// w: HTTP响应写入器
// statusCode: HTTP状态码
// message: 错误消息
// errorCode: 错误代码
// data: 附加数据
func WriteErrorResponse(w http.ResponseWriter, statusCode int, message, errorCode string, data interface{}) {
response := ErrorResponse{
Success: false,
Message: message,
ErrorCode: errorCode,
Data: data,
Timestamp: time.Now().Unix(),
}
WriteJSONResponse(w, statusCode, response)
}
// WriteSuccessResponse 写入成功响应
// w: HTTP响应写入器
// statusCode: HTTP状态码
// message: 成功消息
// data: 响应数据
func WriteSuccessResponse(w http.ResponseWriter, statusCode int, message string, data interface{}) {
response := SuccessResponse{
Success: true,
Message: message,
Data: data,
Timestamp: time.Now().Unix(),
}
WriteJSONResponse(w, statusCode, response)
}
// HandleDatabaseError 处理数据库错误
// w: HTTP响应写入器
// err: 数据库错误
// operation: 操作描述
func HandleDatabaseError(w http.ResponseWriter, err error, operation string) {
if err == gorm.ErrRecordNotFound {
LogWarn(fmt.Sprintf("Record not found during %s", operation), map[string]interface{}{
"operation": operation,
"error": err.Error(),
})
WriteErrorResponse(w, http.StatusNotFound, "记录不存在", ErrCodeNotFound, nil)
return
}
LogError(fmt.Sprintf("Database error during %s", operation), err, map[string]interface{}{
"operation": operation,
})
WriteErrorResponse(w, http.StatusInternalServerError, "数据库操作失败", ErrCodeDatabaseError, nil)
}
// HandleValidationError 处理验证错误
// w: HTTP响应写入器
// message: 验证错误消息
// details: 验证错误详情
func HandleValidationError(w http.ResponseWriter, message string, details interface{}) {
LogWarn("Validation error: "+message, map[string]interface{}{
"details": details,
})
WriteErrorResponse(w, http.StatusBadRequest, message, ErrCodeValidationError, details)
}
// HandleUnauthorizedError 处理未授权错误
// w: HTTP响应写入器
// message: 错误消息
func HandleUnauthorizedError(w http.ResponseWriter, message string) {
LogWarn("Unauthorized access: "+message, nil)
WriteErrorResponse(w, http.StatusUnauthorized, message, ErrCodeUnauthorized, nil)
}
// HandleInternalError 处理内部错误
// w: HTTP响应写入器
// err: 错误
// operation: 操作描述
func HandleInternalError(w http.ResponseWriter, err error, operation string) {
LogError(fmt.Sprintf("Internal error during %s", operation), err, map[string]interface{}{
"operation": operation,
})
WriteErrorResponse(w, http.StatusInternalServerError, "服务器内部错误", ErrCodeInternalError, nil)
}
// LogInfo 记录信息日志
// message: 日志消息
// context: 上下文信息
func LogInfo(message string, context interface{}) {
logEntry := createLogEntry(LogLevelInfo, message, nil, context)
printLog(logEntry)
}
// LogWarn 记录警告日志
// message: 日志消息
// context: 上下文信息
func LogWarn(message string, context interface{}) {
logEntry := createLogEntry(LogLevelWarn, message, nil, context)
printLog(logEntry)
}
// LogError 记录错误日志
// message: 日志消息
// err: 错误对象
// context: 上下文信息
func LogError(message string, err error, context interface{}) {
errorStr := ""
if err != nil {
errorStr = err.Error()
}
logEntry := createLogEntry(LogLevelError, message, &errorStr, context)
printLog(logEntry)
}
// LogDebug 记录调试日志
// message: 日志消息
// context: 上下文信息
func LogDebug(message string, context interface{}) {
logEntry := createLogEntry(LogLevelDebug, message, nil, context)
printLog(logEntry)
}
// createLogEntry 创建日志条目
// level: 日志级别
// message: 日志消息
// errorStr: 错误字符串
// context: 上下文信息
// 返回: 日志条目
func createLogEntry(level LogLevel, message string, errorStr *string, context interface{}) LogEntry {
_, file, line, _ := runtime.Caller(2)
entry := LogEntry{
Level: level,
Message: message,
Context: context,
Timestamp: time.Now(),
File: file,
Line: line,
}
if errorStr != nil {
entry.Error = *errorStr
}
return entry
}
// printLog 打印日志
// entry: 日志条目
func printLog(entry LogEntry) {
levelStr := getLevelString(entry.Level)
timestamp := entry.Timestamp.Format("2006-01-02 15:04:05")
logMessage := fmt.Sprintf("[%s] %s %s", levelStr, timestamp, entry.Message)
if entry.Error != "" {
logMessage += fmt.Sprintf(" | Error: %s", entry.Error)
}
if entry.Context != nil {
contextJSON, _ := json.Marshal(entry.Context)
logMessage += fmt.Sprintf(" | Context: %s", string(contextJSON))
}
logMessage += fmt.Sprintf(" | %s:%d", entry.File, entry.Line)
log.Println(logMessage)
}
// getLevelString 获取日志级别字符串
// level: 日志级别
// 返回: 级别字符串
func getLevelString(level LogLevel) string {
switch level {
case LogLevelInfo:
return "INFO"
case LogLevelWarn:
return "WARN"
case LogLevelError:
return "ERROR"
case LogLevelDebug:
return "DEBUG"
default:
return "UNKNOWN"
}
}

59
utils/logger/http.go Normal file
View File

@@ -0,0 +1,59 @@
package logger
import (
"fmt"
"os"
"time"
)
// LogRequest 记录HTTP请求日志 - 使用标准Apache Common Log Format
// 格式: IP - - [timestamp] "METHOD path HTTP/1.1" status_code response_size
// method: HTTP请求方法
// path: 请求路径
// clientIP: 客户端IP地址
// statusCode: HTTP状态码
// duration: 请求处理时长
func (l *Logger) LogRequest(method, path, clientIP string, statusCode int, duration time.Duration) {
l.LogRequestWithHeaders(method, path, clientIP, statusCode, duration, "-", "-")
}
// LogRequestWithHeaders 记录HTTP请求日志 - 使用修改的Apache Log Format移除Referer字段
// 直接输出标准格式不通过logrus格式化器
// method: HTTP请求方法
// path: 请求路径
// clientIP: 客户端IP地址
// statusCode: HTTP状态码
// duration: 请求处理时长
// referer: 引用页面(已废弃,保留参数兼容性)
// userAgent: 用户代理字符串
func (l *Logger) LogRequestWithHeaders(method, path, clientIP string, statusCode int, duration time.Duration, referer, userAgent string) {
// 格式化时间戳为Apache标准格式
timestamp := time.Now().Format("02/Jan/2006:15:04:05 -0700")
// 处理空值
if userAgent == "" {
userAgent = "-"
}
// 构建修改的HTTP Log格式完全移除Referer字段
logLine := fmt.Sprintf(`%s - - [%s] "%s %s HTTP/1.1" %d - "%s" %dms`,
clientIP,
timestamp,
method,
path,
statusCode,
userAgent,
duration.Milliseconds(),
)
// 直接输出到标准输出和日志文件不使用logrus格式化
l.writeHTTPLog(logLine)
}
// writeHTTPLog 直接输出HTTP日志到标准输出
// 避免Logrus的任何格式化和转义保持Apache日志格式的原始性
// logLine: 格式化后的日志行
func (l *Logger) writeHTTPLog(logLine string) {
// 直接输出到标准输出避免Logrus的转义处理
fmt.Fprintln(os.Stdout, logLine)
}

112
utils/logger/logger.go Normal file
View File

@@ -0,0 +1,112 @@
package logger
import (
log "github.com/sirupsen/logrus"
)
// Logger 日志工具结构体
// 封装logrus.Logger提供统一的日志接口
type Logger struct {
*log.Logger // 嵌入logrus.Logger继承其所有方法
}
// NewLogger 创建新的日志实例使用全局logrus配置
// 返回: 新的Logger实例
func NewLogger() *Logger {
// 使用全局logrus实例而不是创建新实例确保配置一致性
return &Logger{Logger: log.StandardLogger()}
}
// InitLogger 初始化HTTP日志处理器
// 创建专门用于HTTP请求日志的Logger实例使用全局logrus配置
// 返回: 初始化后的Logger实例
func InitLogger() *Logger {
logger := NewLogger()
// HTTP日志使用全局logrus的配置
// 通过使用log.StandardLogger()确保与全局配置保持一致
// 更新全局日志实例
SetGlobalLogger(logger)
return logger
}
// WithFields 添加字段到日志条目
// fields: 要添加的字段映射
// 返回: 包含字段的日志条目
func (l *Logger) WithFields(fields log.Fields) *log.Entry {
return l.Logger.WithFields(fields)
}
// WithField 添加单个字段到日志条目
// key: 字段名
// value: 字段值
// 返回: 包含字段的日志条目
func (l *Logger) WithField(key string, value interface{}) *log.Entry {
return l.Logger.WithField(key, value)
}
// WithError 添加错误字段到日志条目
// err: 要记录的错误
// 返回: 包含错误信息的日志条目
func (l *Logger) WithError(err error) *log.Entry {
return l.Logger.WithError(err)
}
// InfoWithFields 记录带字段的信息级别日志
// msg: 日志消息
// fields: 附加字段
func (l *Logger) InfoWithFields(msg string, fields log.Fields) {
l.WithFields(fields).Info(msg)
}
// ErrorWithFields 记录带字段的错误级别日志
// msg: 日志消息
// fields: 附加字段
func (l *Logger) ErrorWithFields(msg string, fields log.Fields) {
l.WithFields(fields).Error(msg)
}
// WarnWithFields 记录带字段的警告级别日志
// msg: 日志消息
// fields: 附加字段
func (l *Logger) WarnWithFields(msg string, fields log.Fields) {
l.WithFields(fields).Warn(msg)
}
// DebugWithFields 记录带字段的调试级别日志
// msg: 日志消息
// fields: 附加字段
func (l *Logger) DebugWithFields(msg string, fields log.Fields) {
l.WithFields(fields).Debug(msg)
}
// LogError 记录错误日志
// err: 错误对象
// msg: 日志消息
func (l *Logger) LogError(err error, msg string) {
l.WithError(err).Error(msg)
}
// GlobalLogger 全局日志实例
// 提供全局访问的日志记录器
var GlobalLogger *Logger
// init 包初始化函数
// 创建全局日志实例使用全局logrus配置
func init() {
GlobalLogger = NewLogger()
}
// GetLogger 获取全局日志实例
// 返回: 全局Logger实例
func GetLogger() *Logger {
return GlobalLogger
}
// SetGlobalLogger 设置全局日志实例
// logger: 要设置的Logger实例
func SetGlobalLogger(logger *Logger) {
GlobalLogger = logger
}

26
utils/logger/server.go Normal file
View File

@@ -0,0 +1,26 @@
package logger
import (
log "github.com/sirupsen/logrus"
)
// LogServerStart 记录服务器启动日志
// host: 服务器监听地址
// port: 服务器监听端口
func (l *Logger) LogServerStart(host string, port int) {
l.WithFields(log.Fields{
"host": host,
"port": port,
}).Info("HTTP服务器启动")
}
// LogServerStop 记录服务器停止日志
func (l *Logger) LogServerStop() {
l.Info("HTTP服务器停止")
}
// LogConfigLoad 记录配置加载日志
// configFile: 配置文件路径
func (l *Logger) LogConfigLoad(configFile string) {
l.WithField("config_file", configFile).Info("配置文件加载")
}

44
utils/timeutil/server.go Normal file
View File

@@ -0,0 +1,44 @@
package timeutil
import (
"fmt"
"time"
)
// serverStartTime 记录进程启动时间(近似服务器启动时间)
var serverStartTime = time.Now()
// GetServerStartTime 获取服务器启动时间
// 返回: 服务器启动的时间戳
func GetServerStartTime() time.Time {
return serverStartTime
}
// GetServerUptime 获取服务器运行时长
// 返回: 从服务器启动到现在的时间间隔
func GetServerUptime() time.Duration {
return time.Since(serverStartTime)
}
// GetServerUptimeString 获取服务器运行时长的字符串表示
// 返回: 格式化的运行时长字符串
func GetServerUptimeString() string {
duration := time.Since(serverStartTime)
// 获取总秒数并转换为整数
totalSeconds := int(duration.Seconds())
// 计算小时、分钟、秒
hours := totalSeconds / 3600
minutes := (totalSeconds % 3600) / 60
seconds := totalSeconds % 60
// 根据时长长度选择合适的格式
if hours > 0 {
return fmt.Sprintf("%dh%dm%ds", hours, minutes, seconds)
} else if minutes > 0 {
return fmt.Sprintf("%dm%ds", minutes, seconds)
} else {
return fmt.Sprintf("%ds", seconds)
}
}

11
web/assets/logo.svg Normal file
View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#2563eb"/>
<stop offset="100%" stop-color="#60a5fa"/>
</linearGradient>
</defs>
<rect x="8" y="8" width="112" height="112" rx="20" fill="url(#g)"/>
<path d="M64 28 L86 64 L64 100 L42 64 Z" fill="#fff" opacity="0.95"/>
<circle cx="64" cy="64" r="10" fill="#2563eb"/>
</svg>

After

Width:  |  Height:  |  Size: 491 B

355
web/assets/themes.json Normal file
View File

@@ -0,0 +1,355 @@
{
"Default": {
"--color-white": "#FFFFFF",
"--color-black": "#000000",
"--lay-color-white": "#FAFAFA",
"--lay-color-black": "#333333",
"--lay-color-red-1": "#FFF1E8",
"--lay-color-red-2": "#FFD7C0",
"--lay-color-red-3": "#FFBB99",
"--lay-color-red-4": "#FF9C71",
"--lay-color-red-5": "#FF7A4A",
"--lay-color-red-6": "#FF5722",
"--lay-color-red-7": "#D23B15",
"--lay-color-red-8": "#A6250B",
"--lay-color-red-9": "#791404",
"--lay-color-red-10": "#4D0800",
"--lay-color-blue-1": "#E8F9FF",
"--lay-color-blue-2": "#C0ECFF",
"--lay-color-blue-3": "#97DCFF",
"--lay-color-blue-4": "#6FCAFF",
"--lay-color-blue-5": "#46B5FF",
"--lay-color-blue-6": "#1E9FFF",
"--lay-color-blue-7": "#1379D2",
"--lay-color-blue-8": "#0A58A6",
"--lay-color-blue-9": "#043A79",
"--lay-color-blue-10": "#00214D",
"--lay-color-lightblue-1": "#E8FDFF",
"--lay-color-lightblue-2": "#C1F4FB",
"--lay-color-lightblue-3": "#9CEAF7",
"--lay-color-lightblue-4": "#77DDF4",
"--lay-color-lightblue-5": "#53CEF0",
"--lay-color-lightblue-6": "#31BDEC",
"--lay-color-lightblue-7": "#1F95C4",
"--lay-color-lightblue-8": "#10709C",
"--lay-color-lightblue-9": "#064E74",
"--lay-color-lightblue-10": "#002F4D",
"--lay-color-layuigreen-1": "#E8FFF9",
"--lay-color-layuigreen-2": "#B5F1E3",
"--lay-color-layuigreen-3": "#87E3D1",
"--lay-color-layuigreen-4": "#5DD6C1",
"--lay-color-layuigreen-5": "#37C8B5",
"--lay-color-layuigreen-6": "#16BAAA",
"--lay-color-layuigreen-7": "#0E9F95",
"--lay-color-layuigreen-8": "#08837F",
"--lay-color-layuigreen-9": "#036868",
"--lay-color-layuigreen-10": "#004A4D",
"--lay-color-green-1": "#E8FFF2",
"--lay-color-green-2": "#B5F1D1",
"--lay-color-green-3": "#86E2B4",
"--lay-color-green-4": "#5CD49C",
"--lay-color-green-5": "#37C588",
"--lay-color-green-6": "#16B777",
"--lay-color-green-7": "#0E9C68",
"--lay-color-green-8": "#088259",
"--lay-color-green-9": "#036749",
"--lay-color-green-10": "#004D38",
"--lay-color-orange-1": "#FFFCE8",
"--lay-color-orange-2": "#FFF5BA",
"--lay-color-orange-3": "#FFEA8B",
"--lay-color-orange-4": "#FFDC5D",
"--lay-color-orange-5": "#FFCB2E",
"--lay-color-orange-6": "#FFB800",
"--lay-color-orange-7": "#D29000",
"--lay-color-orange-8": "#A66C00",
"--lay-color-orange-9": "#794B00",
"--lay-color-orange-10": "#4D2D00",
"--lay-color-cyan-1": "#E8F6FF",
"--lay-color-cyan-2": "#B9CEDD",
"--lay-color-cyan-3": "#8FA7BB",
"--lay-color-cyan-4": "#6A829A",
"--lay-color-cyan-5": "#4A5F78",
"--lay-color-cyan-6": "#2F4056",
"--lay-color-cyan-7": "#223654",
"--lay-color-cyan-8": "#162C51",
"--lay-color-cyan-9": "#0B214F",
"--lay-color-cyan-10": "#00174D",
"--lay-color-purple-1": "#FDE8FF",
"--lay-color-purple-2": "#EDBEF4",
"--lay-color-purple-3": "#DC97E8",
"--lay-color-purple-4": "#C972DD",
"--lay-color-purple-5": "#B651D1",
"--lay-color-purple-6": "#A233C6",
"--lay-color-purple-7": "#8120A8",
"--lay-color-purple-8": "#631289",
"--lay-color-purple-9": "#48076B",
"--lay-color-purple-10": "#2F004D",
"--lay-color-black-1": "#E8F8FF",
"--lay-color-black-2": "#BFD0D8",
"--lay-color-black-3": "#98A8B1",
"--lay-color-black-4": "#73818A",
"--lay-color-black-5": "#505B63",
"--lay-color-black-6": "#2F363C",
"--lay-color-black-7": "#23303C",
"--lay-color-black-8": "#18293C",
"--lay-color-black-9": "#0C213C",
"--lay-color-black-10": "#00183C",
"--lay-color-gray-1": "#FAFAFA",
"--lay-color-gray-2": "#F6F6F6",
"--lay-color-gray-3": "#EEEEEE",
"--lay-color-gray-4": "#E2E2E2",
"--lay-color-gray-5": "#DDDDDD",
"--lay-color-gray-6": "#D2D2D2",
"--lay-color-gray-7": "#CCCCCC",
"--lay-color-gray-8": "#C2C2C2",
"--lay-color-gray-9": "#AAAAAA",
"--lay-color-gray-10": "#939393",
"--lay-color-gray-11": "#858585",
"--lay-color-gray-12": "#7b7b7b",
"--lay-color-gray-13": "#686868",
"--lay-color-primary": "var(--lay-color-layuigreen-6)",
"--lay-color-primary-hover": "var(--lay-color-layuigreen-5)",
"--lay-color-primary-active": "var(--lay-color-layuigreen-7)",
"--lay-color-primary-disabled": "var(--lay-color-layuigreen-3)",
"--lay-color-primary-light": "var(--lay-color-layuigreen-4)",
"--lay-color-secondary": "var(--lay-color-green-6)",
"--lay-color-secondary-hover": "var(--lay-color-green-5)",
"--lay-color-secondary-active": "var(--lay-color-green-7)",
"--lay-color-secondary-disabled": "var(--lay-color-green-3)",
"--lay-color-secondary-light": "var(--lay-color-green-4)",
"--lay-color-info": "var(--lay-color-lightblue-6)",
"--lay-color-info-hover": "var(--lay-color-lightblue-5)",
"--lay-color-info-active": "var(--lay-color-lightblue-7)",
"--lay-color-info-disabled": "var(--lay-color-lightblue-3)",
"--lay-color-info-light": "var(--lay-color-lightblue-4)",
"--lay-color-normal": "var(--lay-color-blue-6)",
"--lay-color-normal-hover": "var(--lay-color-blue-5)",
"--lay-color-normal-active": "var(--lay-color-blue-7)",
"--lay-color-normal-disabled": "var(--lay-color-blue-3)",
"--lay-color-normal-light": "var(--lay-color-blue-4)",
"--lay-color-warning": "var(--lay-color-orange-6)",
"--lay-color-warning-hover": "var(--lay-color-orange-5)",
"--lay-color-warning-active": "var(--lay-color-orange-7)",
"--lay-color-warning-disabled": "var(--lay-color-orange-3)",
"--lay-color-warning-light": "var(--lay-color-orange-4)",
"--lay-color-success": "var(--lay-color-green-6)",
"--lay-color-success-hover": "var(--lay-color-green-5)",
"--lay-color-success-active": "var(--lay-color-green-7)",
"--lay-color-success-disabled": "var(--lay-color-green-3)",
"--lay-color-success-light": "var(--lay-color-green-4)",
"--lay-color-danger": "var(--lay-color-red-6)",
"--lay-color-danger-hover": "var(--lay-color-red-5)",
"--lay-color-danger-active": "var(--lay-color-red-7)",
"--lay-color-danger-disabled": "var(--lay-color-red-3)",
"--lay-color-danger-light": "var(--lay-color-red-4)",
"--lay-color-bg-1": "#17171A",
"--lay-color-bg-2": "#232324",
"--lay-color-bg-3": "#2a2a2b",
"--lay-color-bg-4": "#313132",
"--lay-color-bg-5": "#373739",
"--lay-color-bg-white": "#f6f6f6",
"--lay-color-text-1": "rgba(255,255,255,.9)",
"--lay-color-text-2": "rgba(255,255,255,.7)",
"--lay-color-text-3": "rgba(255,255,255,.5)",
"--lay-color-text-4": "rgba(255,255,255,.3)",
"--lay-color-border-1": "#2e2e30",
"--lay-color-border-2": "#484849",
"--lay-color-border-3": "#5f5f60",
"--lay-color-border-4": "#929293",
"--lay-color-fill-1": "rgba(255,255,255,.04)",
"--lay-color-fill-2": "rgba(255,255,255,.08)",
"--lay-color-fill-3": "rgba(255,255,255,.12)",
"--lay-color-fill-4": "rgba(255,255,255,.16)",
"--lay-color-hover": "var(--lay-color-fill-3)",
"--lay-color-active": "var(--lay-color-fill-3)",
"--lay-shadow-1": "0 4px 6px rgba(0, 0, 0, 6%), 0 1px 10px rgba(0, 0, 0, 8%), 0 2px 4px rgba(0, 0, 0, 12%)",
"--lay-shadow-2": "0 8px 10px rgba(0, 0, 0, 12%), 0 3px 14px rgba(0, 0, 0, 10%), 0 5px 5px rgba(0, 0, 0, 16%)",
"--lay-shadow-3": "0 16px 24px rgba(0, 0, 0, 14%), 0 6px 30px rgba(0, 0, 0, 12%), 0 8px 10px rgba(0, 0, 0, 20%)"
},
"ColorPaletteDark": {
"--lay-color-red-1": "#4D0800",
"--lay-color-red-2": "#791505",
"--lay-color-red-3": "#A62A11",
"--lay-color-red-4": "#D24622",
"--lay-color-red-5": "#FF6839",
"--lay-color-red-6": "#FF7948",
"--lay-color-red-7": "#FF9C71",
"--lay-color-red-8": "#FFBC9A",
"--lay-color-red-9": "#FFD9C3",
"--lay-color-red-10": "#FFF3EB",
"--lay-color-blue-1": "#00214D",
"--lay-color-blue-2": "#033A79",
"--lay-color-blue-3": "#0F5AA6",
"--lay-color-blue-4": "#1F7FD2",
"--lay-color-blue-5": "#35A9FF",
"--lay-color-blue-6": "#44B4FF",
"--lay-color-blue-7": "#70CAFF",
"--lay-color-blue-8": "#9BDDFF",
"--lay-color-blue-9": "#C6EEFF",
"--lay-color-blue-10": "#F2FCFF",
"--lay-color-lightblue-1": "#002F4D",
"--lay-color-lightblue-2": "#044D74",
"--lay-color-lightblue-3": "#12719C",
"--lay-color-lightblue-4": "#2797C4",
"--lay-color-lightblue-5": "#42C1EC",
"--lay-color-lightblue-6": "#56CFF0",
"--lay-color-lightblue-7": "#79DDF4",
"--lay-color-lightblue-8": "#9DEAF7",
"--lay-color-lightblue-9": "#C3F4FB",
"--lay-color-lightblue-10": "#EAFDFF",
"--lay-color-layuigreen-1": "#004A4D",
"--lay-color-layuigreen-2": "#046868",
"--lay-color-layuigreen-3": "#0E837F",
"--lay-color-layuigreen-4": "#1C9F96",
"--lay-color-layuigreen-5": "#2EBAAC",
"--lay-color-layuigreen-6": "#40C8B6",
"--lay-color-layuigreen-7": "#64D6C2",
"--lay-color-layuigreen-8": "#8CE3D2",
"--lay-color-layuigreen-9": "#B9F1E4",
"--lay-color-layuigreen-10": "#EAFFFA",
"--lay-color-green-1": "#004D38",
"--lay-color-green-2": "#046749",
"--lay-color-green-3": "#0E825B",
"--lay-color-green-4": "#1C9C6D",
"--lay-color-green-5": "#2EB780",
"--lay-color-green-6": "#3FC58B",
"--lay-color-green-7": "#64D4A0",
"--lay-color-green-8": "#8CE2B7",
"--lay-color-green-9": "#BAF1D3",
"--lay-color-green-10": "#EBFFF4",
"--lay-color-orange-1": "#4D2D00",
"--lay-color-orange-2": "#794C04",
"--lay-color-orange-3": "#A66F0A",
"--lay-color-orange-4": "#D29613",
"--lay-color-orange-5": "#FFC11F",
"--lay-color-orange-6": "#FFC926",
"--lay-color-orange-7": "#FFDB57",
"--lay-color-orange-8": "#FFE987",
"--lay-color-orange-9": "#FFF5B8",
"--lay-color-orange-10": "#FFFCE8",
"--lay-color-cyan-1": "#00174D",
"--lay-color-cyan-2": "#0B214F",
"--lay-color-cyan-3": "#162C51",
"--lay-color-cyan-4": "#233754",
"--lay-color-cyan-5": "#304056",
"--lay-color-cyan-6": "#546478",
"--lay-color-cyan-7": "#75879A",
"--lay-color-cyan-8": "#99ABBB",
"--lay-color-cyan-9": "#C2D2DD",
"--lay-color-cyan-10": "#EFF9FF",
"--lay-color-purple-1": "#2F004D",
"--lay-color-purple-2": "#47056B",
"--lay-color-purple-3": "#631389",
"--lay-color-purple-4": "#8326A8",
"--lay-color-purple-5": "#A53FC6",
"--lay-color-purple-6": "#B755D1",
"--lay-color-purple-7": "#CA77DD",
"--lay-color-purple-8": "#DD9BE8",
"--lay-color-purple-9": "#EEC3F4",
"--lay-color-purple-10": "#FDEDFF"
},
"ColorPaletteLight": {
"--lay-color-red-1": "#FFF1E8",
"--lay-color-red-2": "#FFD7C0",
"--lay-color-red-3": "#FFBB99",
"--lay-color-red-4": "#FF9C71",
"--lay-color-red-5": "#FF7A4A",
"--lay-color-red-6": "#FF5722",
"--lay-color-red-7": "#D23B15",
"--lay-color-red-8": "#A6250B",
"--lay-color-red-9": "#791404",
"--lay-color-red-10": "#4D0800",
"--lay-color-blue-1": "#E8F9FF",
"--lay-color-blue-2": "#C0ECFF",
"--lay-color-blue-3": "#97DCFF",
"--lay-color-blue-4": "#6FCAFF",
"--lay-color-blue-5": "#46B5FF",
"--lay-color-blue-6": "#1E9FFF",
"--lay-color-blue-7": "#1379D2",
"--lay-color-blue-8": "#0A58A6",
"--lay-color-blue-9": "#043A79",
"--lay-color-blue-10": "#00214D",
"--lay-color-lightblue-1": "#E8FDFF",
"--lay-color-lightblue-2": "#C1F4FB",
"--lay-color-lightblue-3": "#9CEAF7",
"--lay-color-lightblue-4": "#77DDF4",
"--lay-color-lightblue-5": "#53CEF0",
"--lay-color-lightblue-6": "#31BDEC",
"--lay-color-lightblue-7": "#1F95C4",
"--lay-color-lightblue-8": "#10709C",
"--lay-color-lightblue-9": "#064E74",
"--lay-color-lightblue-10": "#002F4D",
"--lay-color-layuigreen-1": "#E8FFF9",
"--lay-color-layuigreen-2": "#B5F1E3",
"--lay-color-layuigreen-3": "#87E3D1",
"--lay-color-layuigreen-4": "#5DD6C1",
"--lay-color-layuigreen-5": "#37C8B5",
"--lay-color-layuigreen-6": "#16BAAA",
"--lay-color-layuigreen-7": "#0E9F95",
"--lay-color-layuigreen-8": "#08837F",
"--lay-color-layuigreen-9": "#036868",
"--lay-color-layuigreen-10": "#004A4D",
"--lay-color-green-1": "#E8FFF2",
"--lay-color-green-2": "#B5F1D1",
"--lay-color-green-3": "#86E2B4",
"--lay-color-green-4": "#5CD49C",
"--lay-color-green-5": "#37C588",
"--lay-color-green-6": "#16B777",
"--lay-color-green-7": "#0E9C68",
"--lay-color-green-8": "#088259",
"--lay-color-green-9": "#036749",
"--lay-color-green-10": "#004D38",
"--lay-color-orange-1": "#FFFCE8",
"--lay-color-orange-2": "#FFF5BA",
"--lay-color-orange-3": "#FFEA8B",
"--lay-color-orange-4": "#FFDC5D",
"--lay-color-orange-5": "#FFCB2E",
"--lay-color-orange-6": "#FFB800",
"--lay-color-orange-7": "#D29000",
"--lay-color-orange-8": "#A66C00",
"--lay-color-orange-9": "#794B00",
"--lay-color-orange-10": "#4D2D00",
"--lay-color-cyan-1": "#E8F6FF",
"--lay-color-cyan-2": "#B9CEDD",
"--lay-color-cyan-3": "#8FA7BB",
"--lay-color-cyan-4": "#6A829A",
"--lay-color-cyan-5": "#4A5F78",
"--lay-color-cyan-6": "#2F4056",
"--lay-color-cyan-7": "#223654",
"--lay-color-cyan-8": "#162C51",
"--lay-color-cyan-9": "#0B214F",
"--lay-color-cyan-10": "#00174D",
"--lay-color-purple-1": "#FDE8FF",
"--lay-color-purple-2": "#EDBEF4",
"--lay-color-purple-3": "#DC97E8",
"--lay-color-purple-4": "#C972DD",
"--lay-color-purple-5": "#B651D1",
"--lay-color-purple-6": "#A233C6",
"--lay-color-purple-7": "#8120A8",
"--lay-color-purple-8": "#631289",
"--lay-color-purple-9": "#48076B",
"--lay-color-purple-10": "#2F004D"
},
"editable": {
"--lay-color-bg-1": "#17171A",
"--lay-color-bg-2": "#232324",
"--lay-color-bg-3": "#2a2a2b",
"--lay-color-bg-4": "#313132",
"--lay-color-bg-5": "#373739",
"--lay-color-bg-white": "#f6f6f6",
"--lay-color-text-1": "rgba(255,255,255,.9)",
"--lay-color-text-2": "rgba(255,255,255,.7)",
"--lay-color-text-3": "rgba(255,255,255,.5)",
"--lay-color-text-4": "rgba(255,255,255,.3)",
"--lay-color-border-1": "#2e2e30",
"--lay-color-border-2": "#484849",
"--lay-color-border-3": "#5f5f60",
"--lay-color-border-4": "#929293",
"--lay-color-fill-1": "rgba(255,255,255,.04)",
"--lay-color-fill-2": "rgba(255,255,255,.08)",
"--lay-color-fill-3": "rgba(255,255,255,.12)",
"--lay-color-fill-4": "rgba(255,255,255,.16)",
"--lay-color-hover": "var(--lay-color-fill-3)",
"--lay-color-active": "var(--lay-color-fill-3)"
}
}

67
web/public.go Normal file
View File

@@ -0,0 +1,67 @@
package web
import (
"embed"
"html/template"
"io/fs"
"log"
"os"
"path/filepath"
"github.com/spf13/viper"
)
// TemplatesFS 嵌入模板的文件系统
//
//go:embed template/*.html template/admin/*.html
var templatesFS embed.FS
// StaticFS 嵌入静态资源的文件系统(包含 CSS/JS 的 static 与 图片/字体等资源的 assets
//
//go:embed static/* assets/*
var staticFS embed.FS
// getDistRootFS 获取基于 server.dist 的本地文件系统
// 当 server.dist 非空且路径存在时,返回对应的本地只读 FS否则返回 nil
func getDistRootFS() fs.FS {
// 从配置中读取 server.dist
distPath := viper.GetString("server.dist")
if distPath == "" {
return nil
}
// 归一化路径,兼容相对/绝对
absPath := distPath
if !filepath.IsAbs(distPath) {
if p, err := filepath.Abs(distPath); err == nil {
absPath = p
}
}
// 检查目录是否存在
if info, err := os.Stat(absPath); err == nil && info.IsDir() {
return os.DirFS(absPath)
}
log.Printf("server.dist 路径无效或不可访问:%s将回退使用嵌入资源", distPath)
return nil
}
// ParseTemplates 解析模板
// 优先从 server.dist 指定目录加载(当配置非空且有效),否则回退到嵌入模板
func ParseTemplates() (*template.Template, error) { // Go 顶级函数不支持箭头写法
if distFS := getDistRootFS(); distFS != nil {
// 期望 dist 目录下存在 template 与 template/admin 结构
// 如:{dist}/template/*.html 与 {dist}/template/admin/*.html
return template.ParseFS(distFS, "template/*.html", "template/admin/*.html")
}
// 默认:使用嵌入模板
return template.ParseFS(templatesFS, "template/*.html", "template/admin/*.html")
}
// GetStaticFS 返回静态资源文件系统(包含 static 与 assets 目录)
// 优先使用 server.dist 指定的本地目录;否则回退到嵌入静态资源
func GetStaticFS() (fs.FS, error) { // Go 顶级函数不支持箭头写法
if distFS := getDistRootFS(); distFS != nil {
// 直接返回以 dist 根为起点的 FSroutes 中会再基于此 FS Sub 出 static 与 assets
return distFS, nil
}
return staticFS, nil
}

104
web/static/css/admin.css Normal file
View File

@@ -0,0 +1,104 @@
wc-include{padding: 15px;display: block;}
#app {display: none;}
.layui-layout-right .layui-nav-bar {background-color: unset !important;}
.layui-layout-admin .layui-side {top: 0 !important;z-index: 1001;}
.layui-layout-admin .layui-logo {position: relative !important;height: 60px !important;top: -2px !important;}
/* Logo文字美化样式 */
.logo-enhanced {
font-family: 'Microsoft YaHei', 'PingFang SC', 'Helvetica Neue', Arial, sans-serif !important;
font-size: 18px !important;
font-weight: 600 !important;
color: #ffffff !important;
text-shadow: 0 1px 2px rgba(0,0,0,0.3) !important;
letter-spacing: 1px !important;
}
.layui-side,
.layui-header,
.layui-body,
.layui-footer {transition: left 0.3s;}
.collapse .layui-layout-admin .layui-side,
.collapse .layui-layout-admin .layui-header {left: -200px;}
.collapse .layui-layout-admin .layui-footer,
.collapse .layui-layout-admin .layui-body {left: 0px;}
::view-transition-old(root),
::view-transition-new(root) {animation: none;mix-blend-mode: normal;}
::view-transition-old(root) {z-index: 9999;}
::view-transition-new(root) {z-index: 1;}
.dark::view-transition-old(root) {z-index: 1;}
.dark::view-transition-new(root) {z-index: 9999;}
/* 以下为自定义样式 */
.system-info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; margin-bottom: 20px; }
.system-info-item { padding: 16px; border-radius: 8px; background: var(--card); border: 1px solid var(--border); }
.system-info-label { font-size: 14px; color: var(--muted); margin-bottom: 8px; }
.system-info-value { font-size: 16px; font-weight: 600; color: var(--fg); }
/* ===================== 滚动条美化与布局约束(右侧滑块条) ===================== */
/*
作用:
1. 统一 Admin 布局下内容区(.layui-body为局部滚动容器只在头部与页脚之间滚动
2. 美化 .layui-body 的滚动条样式,增强可用性与观感
3. 不影响登录页等非 Admin 布局页面(仅在 .layui-layout-admin 作用域内生效)
*/
:root {
/* 头部与页脚的高度变量,便于后续维护/调整 */
--admin-header-h: 60px;
--admin-footer-h: 0px; /* 当前页脚未启用,如启用可改为 44px 等 */
}
/* Admin 主容器占满视口,高度锁定,避免出现浏览器右侧全局滚动条 */
.layui-layout-admin {
position: relative;
height: 100vh;
overflow: hidden;
}
/* 头部/页脚高度同步到变量,确保与内容区上下边界垂直齐平 */
.layui-layout-admin .layui-header {
height: var(--admin-header-h);
line-height: var(--admin-header-h);
}
.layui-layout-admin .layui-footer {
height: var(--admin-footer-h);
line-height: var(--admin-footer-h);
}
/* 内容区设为局部滚动容器,顶部/底部与头部/页脚精确对齐 */
.layui-layout-admin .layui-body {
/* 仅约束垂直方向,左右定位保持与 Layui 默认一致,兼容现有折叠动画 */
top: var(--admin-header-h) !important;
bottom: var(--admin-footer-h) !important;
overflow: auto;
/* Firefox 滚动条样式(细滚动条+自定义颜色) */
scrollbar-width: thin; /* 细滚动条 */
scrollbar-color: var(--lay-color-secondary) var(--lay-color-bg-3); /* thumb 与 track 颜色 */
}
/* WebKit 滚动条样式Chrome/Edge/Safari */
.layui-layout-admin .layui-body::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.layui-layout-admin .layui-body::-webkit-scrollbar-track {
background: var(--lay-color-bg-2);
border-left: 1px solid var(--lay-color-border-2);
}
.layui-layout-admin .layui-body::-webkit-scrollbar-thumb {
/* 渐变+内边透明边框,获得圆润质感 */
background: linear-gradient(180deg, var(--lay-color-gray-7), var(--lay-color-gray-9));
border-radius: 8px;
border: 2px solid transparent;
background-clip: padding-box;
}
.layui-layout-admin .layui-body::-webkit-scrollbar-thumb:hover {
background: var(--lay-color-secondary); /* 悬停高亮,强化可交互性 */
}
.layui-layout-admin .layui-body::-webkit-scrollbar-corner {
background: transparent;
}
/* ===================== END 滚动条美化与布局约束 ===================== */

178
web/static/css/home.css Normal file
View File

@@ -0,0 +1,178 @@
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Microsoft YaHei', sans-serif;
}
.card-container {
background: rgba(255, 255, 255, 0.95);
border-radius: 15px;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.2);
padding: 40px;
width: 100%;
max-width: 500px;
backdrop-filter: blur(10px);
}
.header {
text-align: center;
margin-bottom: 30px;
}
.header h1 {
color: #333;
font-size: 28px;
margin-bottom: 10px;
font-weight: 300;
}
.header p {
color: #666;
font-size: 14px;
}
.progress-container {
margin-bottom: 30px;
}
.progress-steps {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
position: relative;
}
.progress-steps::before {
content: '';
position: absolute;
top: 20px;
left: 20px;
right: 20px;
height: 2px;
background: #e6e6e6;
z-index: 1;
}
.progress-line {
position: absolute;
top: 20px;
left: 20px;
height: 2px;
background: #5FB878;
transition: width 0.5s ease;
z-index: 2;
width: 0%;
}
.step {
position: relative;
z-index: 3;
text-align: center;
flex: 1;
}
.step-circle {
width: 40px;
height: 40px;
border-radius: 50%;
background: #e6e6e6;
color: #999;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 10px;
font-weight: bold;
transition: all 0.3s ease;
}
.step.active .step-circle {
background: #5FB878;
color: white;
}
.step.completed .step-circle {
background: #5FB878;
color: white;
}
.step-text {
font-size: 12px;
color: #666;
}
.step.active .step-text {
color: #5FB878;
font-weight: bold;
}
.form-container {
margin-top: 20px;
}
.form-item {
margin-bottom: 20px;
}
.form-item label {
display: block;
margin-bottom: 8px;
color: #333;
font-weight: 500;
}
.layui-input {
border-radius: 8px;
border: 1px solid #e6e6e6;
padding: 12px 15px;
font-size: 14px;
transition: border-color 0.3s ease;
}
.layui-input:focus {
border-color: #5FB878;
box-shadow: 0 0 0 2px rgba(95, 184, 120, 0.2);
}
.submit-btn {
width: 100%;
height: 45px;
background: linear-gradient(45deg, #5FB878, #42B983);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 20px;
}
.submit-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(95, 184, 120, 0.4);
}
.submit-btn:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.hidden {
display: none;
}
.loading {
text-align: center;
padding: 20px;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #5FB878;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 修复 select 下拉在某些浏览器中文字垂直被裁剪问题 */
select.layui-input,
select.layui-select {
/* 统一高度,避免被 padding 挤压导致文字显示不全 */
height: 40px;
line-height: 40px; /* 对多数浏览器有效,确保文字垂直居中 */
padding: 0 15px; /* 与 .layui-input 保持一致的水平内边距 */
box-sizing: border-box; /* 使高度计算更可控,不受 padding 影响 */
vertical-align: middle;
}
/* 兼容性优化:在部分内核下 select 需要明确字体大小与行高匹配 */
select.layui-input option,
select.layui-select option {
line-height: 40px;
}

330
web/static/js/admin.js Executable file
View File

@@ -0,0 +1,330 @@
const VERSION = '2.10.1';
const layuicss = `https://unpkg.com/layui@${VERSION}/dist/css/layui.css`;
const layuijs = `https://unpkg.com/layui@${VERSION}/dist/layui.js`;
const rootPath = (function (src) {
src = (document.currentScript && document.currentScript.tagName.toUpperCase() === 'SCRIPT') ? document.currentScript.src : document.scripts[document.scripts.length - 1].src;
return src.substring(0, src.lastIndexOf('/') + 1);
})();
const app = document.querySelector('#app')
addLink({ href: layuicss }).then(() => {
app.style.display = 'block';
});
addLink({ id: 'layui_theme_css', href: `./static/src/layui-theme-dark-selector.css` });
// TODO 弃用,下个版本只支持选择器模式
//addLink({ id: 'layui_theme_css', href: `${rootPath}dist/layui-theme-dark.css` });
loadScript(layuijs, function () {
layui
.config({
base: './static/lib/',
})
.extend({
drawer: 'drawer/drawer',
});
layui.use(['drawer', 'colorMode'], function () {
const { $, element, form, layer, util, dropdown, drawer, colorMode } = layui;
const APPERANCE_KEY = 'layui-theme-demo-prefer-dark';
const theme = colorMode.init({
selector: 'html',
attribute: 'class',
initialValue: 'dark',
modes: {
light: '',
dark: 'dark',
},
storageKey: APPERANCE_KEY,
onChanged(mode, defaultHandler) {
const isAppearanceTransition = document.startViewTransition && !window.matchMedia(`(prefers-reduced-motion: reduce)`).matches;
const isDark = mode === 'dark';
$('#change-theme').attr('class', `layui-icon layui-icon-${isDark ? 'moon' : 'light'}`);
if (!isAppearanceTransition) {
defaultHandler();
} else {
rippleViewTransition(isDark, function () {
defaultHandler();
});
}
},
});
routerTo({path: location.hash.slice(1) || 'dashboard'});
dropdown.render({
elem: '#change-theme',
align: 'center',
data: [
{
title: '深色模式',
id: 'dark',
icon: 'layui-icon-moon',
},
{
title: '浅色模式',
id: 'light',
icon: 'layui-icon-light',
},
{
title: '跟随系统',
id: 'auto',
icon: 'layui-icon-console',
},
],
templet(d) {
return `
<span style="display: flex;">
<i class="layui-icon ${d.icon}" style="margin-right: 8px"></i>
${d.title}
</span>`.trim();
},
click(obj) {
const { id: mode } = obj;
theme.setMode(mode);
},
});
util.event('lay-header-event', {
menuLeft() {
$('body').toggleClass('collapse');
},
menuRight() {
drawer.open({
area: '600px',
url: './static/tpl/theme.html',
hideOnClose: true,
id: 'drawer-theme-tpl',
shade: 0.01,
});
},
});
element.on('nav(nav-side)', function (elem) {
var path = elem.data('path');
if (path) {
routerTo({path});
if ($(window).width() <= 768) {
$('body').toggleClass('collapse', false);
}
}
});
$('#layuiv').text(layui.v);
/*
* 后台通用脚本
* 说明:统一处理全局的退出登录逻辑,遵循后端 jsonResponse 的返回格式:
* code: 0 表示成功非0表示失败
* msg: 提示信息
* data: 业务数据
*/
// 绑定退出登录按钮事件(箭头函数写法)
const bindLogout = () => {
const btn = document.getElementById('logout-btn');
if (!btn) return;
btn.addEventListener('click', (e) => {
e.preventDefault();
handleLogout();
});
};
// 执行退出登录(箭头函数写法)
// 功能:弹出确认框 -> 显示加载层 -> 调用 /admin/logout -> 依据 code===0 判断
const handleLogout = () => {
layer.confirm('确定要退出登录吗?', {
icon: 3,
title: '提示'
}, (index) => {
layer.close(index);
// 显示加载层
const loadIndex = layer.load(2, {
content: '正在退出登录...'
});
// 调用登出接口
fetch('/admin/logout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
layer.close(loadIndex);
const ok = data && data.code === 0;
const msg = (data && (data.msg || data.message)) || (ok ? '退出登录成功' : '退出登录失败');
if (ok) {
layer.msg(msg, {
icon: 1,
time: 1000
}, () => {
// 跳转到登录页或后端返回的地址
const redirect = (data && data.data && data.data.redirect) || '/admin/login';
window.location.href = redirect;
});
} else {
layer.msg(msg, { icon: 2 });
}
})
.catch(error => {
layer.close(loadIndex);
console.error('登出请求失败:', error);
layer.msg('网络错误,请重试', { icon: 2 });
});
});
};
// 页面就绪后绑定事件(箭头函数写法)
(() => {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindLogout);
} else {
bindLogout();
}
})();
// 刷新页面功能处理
const handleRefresh = () => {
layer.confirm('确定要刷新内容吗?', {
icon: 3,
title: '提示'
}, (index) => {
layer.close(index);
// 获取当前hash值确定当前页面路径
let currentPath = window.location.hash.replace('#', '') || 'dashboard';
// 显示加载层
const loadIndex = layer.load(2, {
content: '正在刷新...'
});
// 延迟一下再刷新内容,让用户看到加载效果
setTimeout(() => {
// 重新加载当前内容页面
routerTo({ path: currentPath });
layer.close(loadIndex);
}, 500);
});
};
// 绑定刷新按钮点击事件
$('#refresh-btn').on('click', handleRefresh);
function routerTo({
elem = '#router-view',
path = 'dashboard',
prefix = 'admin/', //路由前缀
suffix = '', //路由后缀
} = {}) {
var routerView = $(elem);
var url = prefix + path + suffix;
var loadTimer = setTimeout(() => {
layer.load(2);
}, 100);
history.replaceState({}, '', `#${path}`); // 因为并没有处理路由
routerView.attr('src', url)
routerView.off('load').on('load',function(){
element.render();
form.render();
clearTimeout(loadTimer);
layer.closeLast('loading');
})
// 选中, 展开菜单
$('#ws-nav-side')
.find("[data-path='" + path + "']")
.parent('dd')
.addClass('layui-this')
.closest('.layui-nav-item')
.addClass('layui-nav-itemed');
}
});
});
function rippleViewTransition(isDark, callback) {
// 移植自 https://github.com/vuejs/vitepress/pull/2347
// 支持 Chrome 111+
// 兼容 jQuery 3 下隐式 event 全局对象不可用的问题
if (!window.event) {
window.event = new MouseEvent('click', {
clientX: document.documentElement.clientWidth,
clientY: 60,
});
}
const x = event.clientX;
const y = event.clientY;
const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
const transition = document.startViewTransition(function () {
callback && callback();
});
transition.ready.then(function () {
var clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`];
document.documentElement.animate(
{
clipPath: isDark ? clipPath : [...clipPath].reverse(),
},
{
duration: 300,
easing: 'ease-in',
pseudoElement: isDark ? '::view-transition-new(root)' : '::view-transition-old(root)',
}
);
});
}
function addStyle(id, cssStr) {
const el = document.getElementById(id) || document.createElement('style');
if (!el.isConnected) {
el.type = 'text/css';
el.id = id;
document.head.appendChild(el);
}
el.textContent = cssStr;
}
function addLink(opt) {
return new Promise((resolve) => {
const link = Object.assign(document.createElement('link'), {
rel: 'stylesheet',
onload: () => resolve({ ...opt, status: 'success' }),
onerror: () => resolve({ ...opt, status: 'error' }), // 为了在 Promise.all 的使用场景
...opt,
});
document.head.appendChild(link);
});
}
function loadScript(url, callback) {
const script = document.createElement('script');
script.type = 'text/javascript';
script.async = 'async';
script.src = url;
document.body.appendChild(script);
if (script.readyState) {
script.onreadystatechange = function () {
if (script.readyState == 'complete' || script.readyState == 'loaded') {
script.onreadystatechange = null;
callback && callback();
}
};
} else {
script.onload = function () {
callback && callback();
};
}
}

83
web/static/lib/README.md Normal file
View File

@@ -0,0 +1,83 @@
# ColorMode 模块WIP
开箱即用的主题切换(深色/浅色/自定义)模块,具有自动数据持久性。
**基本使用**
```js
layui.use(['colorMode'], function () {
var colorMode = layui.colorMode
var theme = colorMode.init()
}
);
```
**配置**
模块仅处理 DOM 属性更改,以便在 CSS 中应用正确的选择器,不会处理实际的样式,主题或 CSS。
默认情况下,使用 auto 模式(与用户的浏览器首选项匹配),将类 dark 应用于 html 标签时启用深色模式,返回一个对象,用来获取和改变主题。
```js
var theme = colorMode.init()
theme.mode() // 'dark' | 'light'
theme.setMode('dark') // 设置为深色模式并持久化到 localstorage
theme.setMode('auto') // 设置为 auto 模式
```
也可以自定义以使其适用于大多数场景
```js
var theme = colorMode.init({
selector: 'body',
attribute: 'theme-mode',
initialValue: 'light',
modes: {
auto: '',
light: 'light',
dark: 'dark',
contrast: 'dark contrast',
},
storage: localStorage,
storageKey: 'xxx-theme-mode',
disableTransition: true,
})
```
如果上述配置仍不能满足您的需求,可以使用 onChanged 选项完全控制处理更新的方式
```js
var theme = colorMode.init({
onChanged: function(mode, defaultHandler){
// 自定义更新方式
}
})
```
**API**
```ts
/**
* @typedef {object} initOptions
* @prop {string} [selector='html'] - 应用于目标元素的 CSS 选择器
* @prop {string} [attribute='class'] - 应用于目标元素的 HTML 属性
* @prop {string} [initialValue='auto'] - 初始颜色模式
* @prop {Object.<string, string>} [modes]- 颜色模式。value 为添加到 HTML 属性上的值
* @prop {(mode: string, defaultHandler: () => void) => void} [onChanged] - 用于处理更新的自定义处理程序指定时默认行为将被覆盖。mode 为颜色模式defaultHandler 为默认处理程序
* @prop {Storage} [storage=localStorage] - 将数据持久化到 localStorage/sessionStorage 的键。传递 `null` 以禁用持久性
* @prop {string | null} [storageKey='color-scheme'] - 持久化使用的 key
* @prop {boolean} [disableTransition=true] - 禁用切换时的过渡 {@link https://paco.me/writing/disable-theme-transitions}
*
*/
/**
*
* @param {initOptions} options
* @returns {{ mode: () => string; setMode: (mode: string) => void;}}
*/
colorMode.init(options)
```

191
web/static/lib/colorMode.js Executable file
View File

@@ -0,0 +1,191 @@
/**
* WIP
* 移植自 https://github.com/vueuse/vueuse/tree/main/packages/core/useColorMode
*/
// @ts-ignore
layui.define(['jquery'], function (exports) {
'use strict';
/** @type {jQuery}*/
var $ = layui.jquery;
var MOD_NAME = 'colorMode';
var defaultWindow = window;
var document = defaultWindow.document;
var colorMode = {
/**
* @typedef {object} initOptions
* @prop {string} [selector="html"] - 应用于目标元素的 CSS 选择器
* @prop {string} [attribute="class"] - 应用于目标元素的 HTML 属性
* @prop {string} [initialValue='auto'] - 初始颜色模式
* @prop {Object.<string, string>} [modes]- 颜色模式。value 为添加到 HTML 属性上的值
* @prop {(mode: string, defaultHandler: (window?: Window) => void) => void} [onChanged] - 用于处理更新的自定义处理程序,指定时,默认行为将被覆盖。
* @prop {Storage} [storage=localStorage] - 将数据持久化到 localStorage/sessionStorage 的键。传递 `null` 以禁用持久性
* @prop {string | null} [storageKey='color-scheme'] - 持久化使用的 key
* @prop {boolean} [disableTransition=true] - 禁用切换时的过渡 {@link https://paco.me/writing/disable-theme-transitions}
*
*/
/**
*
* @param {initOptions} options
* @returns {{mode: () => string; setMode: (mode: string, window?: Window) => void; }}
*/
init: function (options) {
var defaults = {
selector: 'html',
attribute: 'class',
initialValue: 'auto',
modes: {
auto: '',
light: 'light',
dark: 'dark',
},
storage: localStorage,
storageKey: 'color-scheme',
disableTransition: true,
};
var opts = $.extend(true, {}, defaults, options);
// 当前颜色模式
var state;
// 系统颜色模式
var system;
// 初始化 storage
var store =
opts.storageKey == null
? opts.initialValue
: (function () {
var v = opts.storage.getItem(opts.storageKey);
if (!v) {
opts.storage.setItem(opts.storageKey, opts.initialValue);
return opts.initialValue;
}
return v;
})();
/**
* 更新 HTML 属性值
* @param {String} selector
* @param {String} attribute
* @param {String} value
* @param {Window} win
*/
var updateHTMLAttrs = function (selector, attribute, value, win) {
win = win || defaultWindow;
var document = win.document;
var el = typeof selector === 'string' ? document.querySelector(selector) : undefined;
if (!el) return;
/**@type HTMLStyleElement */
var style;
if (opts.disableTransition) {
style = document.createElement('style');
style.appendChild(
document.createTextNode(
'*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}'
)
);
document.head.appendChild(style);
}
if (attribute === 'class') {
var current = value.split(/\s/g);
$.each(opts.modes, function (_, modeval) {
$.each((modeval || '').split(/\s/g), function (_, v) {
if (!v) return;
if (current.indexOf(v) !== -1) {
el.classList.add(v);
} else {
el.classList.remove(v);
}
});
});
} else {
el.setAttribute(attribute, value);
}
if (opts.disableTransition) {
// 调用 getComputedStyle 强制浏览器重绘
// @ts-expect-error 未使用的变量
var _ = window.getComputedStyle(style).opacity;
document.head.removeChild(style);
}
};
/**
* 更新状态
* @param {String} mode - 颜色模式
*/
var updateState = function (mode) {
store = opts.storageKey == null ? mode : opts.storage.getItem(opts.storageKey);
state = store === 'auto' ? system : store;
};
var prefersColorScheme = function () {
var isSupported = window && 'matchMedia' in window && typeof window.matchMedia === 'function';
if (!isSupported) {
system = 'light';
onChanged(system);
return;
}
var darkThemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
var update = function () {
var preferredDark = darkThemeMediaQuery.matches;
system = preferredDark ? 'dark' : 'light';
onChanged(system);
};
update();
if ('addEventListener' in darkThemeMediaQuery) {
darkThemeMediaQuery.addEventListener('change', update);
} else {
// @ts-ignore 已弃用
darkThemeMediaQuery.addListener(update);
}
};
prefersColorScheme();
function defaultOnChanged(win) {
updateHTMLAttrs(opts.selector, opts.attribute, opts.modes[state], win);
}
function onChanged(mode, win) {
updateState(mode);
if (opts.onChanged) {
opts.onChanged(state, defaultOnChanged);
} else {
defaultOnChanged(win);
}
}
return {
setMode: function (mode, win) {
if (opts.storageKey) {
opts.storage.setItem(opts.storageKey, mode);
}
onChanged(mode, win);
},
mode: function () {
return state;
},
};
},
addStyle: function (id, cssStr) {
var el = /** @type {HTMLStyleElement} */ (document.getElementById(id) || document.createElement('style'));
if (!el.isConnected) {
el.type = 'text/css';
el.id = id;
document.head.appendChild(el);
}
el.textContent = cssStr;
},
};
exports(MOD_NAME, colorMode);
});

View File

@@ -0,0 +1,317 @@
.layer-drawer.layui-layer {
border-radius: 0 !important;
overflow: auto;
}
.layer-drawer.layui-layer.position-absolute {
position: absolute !important;
}
.layer-drawer-anim,
.layer-drawer-anim.layui-anim {
-webkit-animation-duration: .3s;
animation-duration: .3s;
-webkit-animation-timing-function: cubic-bezier(0.7, 0.3, 0.1, 1);
animation-timing-function: cubic-bezier(0.7, 0.3, 0.1, 1);
}
/* right to left */
@keyframes layer-rl {
from {
-webkit-transform: translate3d(100%, 0, 0);
-ms-transform: translate3d(100%, 0, 0);
transform: translate3d(100%, 0, 0);
opacity: 1;
}
to {
-webkit-transform: translate3d(0, 0, 0);
-ms-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
opacity: 1;
}
}
@-webkit-keyframes layer-rl {
from {
-webkit-transform: translate3d(100%, 0, 0);
-ms-transform: translate3d(100%, 0, 0);
transform: translate3d(100%, 0, 0);
opacity: 1;
}
to {
-webkit-transform: translate3d(0, 0, 0);
-ms-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
opacity: 1;
}
}
.layer-anim-rl {
-webkit-animation-name: layer-rl;
animation-name: layer-rl;
}
/* right to left close */
@keyframes layer-rl-close {
from {
-webkit-transform: translate3d(0, 0, 0);
-ms-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
to {
-webkit-transform: translate3d(100%, 0, 0);
-ms-transform: translate3d(100%, 0, 0);
transform: translate3d(100%, 0, 0);
}
}
@-webkit-keyframes layer-rl-close {
from {
-webkit-transform: translate3d(0, 0, 0);
-ms-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
to {
-webkit-transform: translate3d(100%, 0, 0);
-ms-transform: translate3d(100%, 0, 0);
transform: translate3d(100%, 0, 0);
}
}
.layer-anim-rl-close,
.layer-anim-rl.layer-anim-close {
-webkit-animation-name: layer-rl-close;
animation-name: layer-rl-close;
}
/* left to right */
@-webkit-keyframes layer-lr {
from {
-webkit-transform: translate3d(0, 0, 0);
-ms-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
opacity: 1
}
to {
-webkit-transform: translate3d(-100%, 0, 0);
-ms-transform: translate3d(-100%, 0, 0);
transform: translate3d(-100%, 0, 0);
opacity: 1
}
}
@keyframes layer-lr {
from {
-webkit-transform: translate3d(-100%, 0, 0);
-ms-transform: translate3d(-100%, 0, 0);
transform: translate3d(-100%, 0, 0);
opacity: 1
}
to {
-webkit-transform: translate3d(0, 0, 0);
-ms-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
opacity: 1
}
}
.layer-anim-lr {
-webkit-animation-name: layer-lr;
animation-name: layer-lr
}
/* left to right close */
@-webkit-keyframes layer-lr-close {
from {
-webkit-transform: translate3d(0, 0, 0);
-ms-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
to {
-webkit-transform: translate3d(-100%, 0, 0);
-ms-transform: translate3d(-100%, 0, 0);
transform: translate3d(-100%, 0, 0);
}
}
@keyframes layer-lr-close {
from {
-webkit-transform: translate3d(0, 0, 0);
-ms-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
to {
-webkit-transform: translate3d(-100%, 0, 0);
-ms-transform: translate3d(-100%, 0, 0);
transform: translate3d(-100%, 0, 0);
}
}
.layer-anim-lr-close,
.layer-anim-lr.layer-anim-close {
-webkit-animation-name: layer-lr-close;
animation-name: layer-lr-close
}
/* top to bottom */
@-webkit-keyframes layer-tb {
from {
-webkit-transform: translate3d(0, -100%, 0);
-ms-transform: translate3d(0, -100%, 0);
transform: translate3d(0, -100%, 0);
opacity: 1;
animation-timing-function: cubic-bezier(0.7, 0.3, 0.1, 1);
}
to {
-webkit-transform: translate3d(0, 0, 0);
-ms-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
opacity: 1;
animation-timing-function: cubic-bezier(0.7, 0.3, 0.1, 1);
}
}
@keyframes layer-tb {
from {
-webkit-transform: translate3d(0, -100%, 0);
-ms-transform: translate3d(0, -100%, 0);
transform: translate3d(0, -100%, 0);
opacity: 1;
animation-timing-function: cubic-bezier(0.7, 0.3, 0.1, 1);
}
to {
-webkit-transform: translate3d(0, 0, 0);
-ms-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
opacity: 1;
animation-timing-function: cubic-bezier(0.7, 0.3, 0.1, 1);
}
}
.layer-anim-tb {
-webkit-animation-name: layer-tb;
animation-name: layer-tb
}
/* top to bottom close */
@-webkit-keyframes layer-tb-close {
from {
-webkit-transform: translate3d(0, 0, 0);
-ms-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
to {
-webkit-transform: translate3d(0, -100%, 0);
-ms-transform: translate3d(0, -100%, 0);
transform: translate3d(0, -100%, 0);
}
}
@keyframes layer-tb-close {
from {
-webkit-transform: translate3d(0, 0, 0);
-ms-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
to {
-webkit-transform: translate3d(0, -100%, 0);
-ms-transform: translate3d(0, -100%, 0);
transform: translate3d(0, -100%, 0);
}
}
.layer-anim-tb-close,
.layer-anim-tb.layer-anim-close {
-webkit-animation-name: layer-tb-close;
animation-name: layer-tb-close
}
/* bottom to top */
@-webkit-keyframes layer-bt {
from {
-webkit-transform: translate3d(0, 100%, 0);
-ms-transform: translate3d(0, 100%, 0);
transform: translate3d(0, 100%, 0);
opacity: 1
}
to {
-webkit-transform: translate3d(0, 0, 0);
-ms-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
opacity: 1
}
}
@keyframes layer-bt {
from {
-webkit-transform: translate3d(0, 100%, 0);
-ms-transform: translate3d(0, 100%, 0);
transform: translate3d(0, 100%, 0);
opacity: 1
}
to {
-webkit-transform: translate3d(0, 0, 0);
-ms-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
opacity: 1
}
}
.layer-anim-bt {
-webkit-animation-name: layer-bt;
animation-name: layer-bt
}
/* bottom to top close */
@-webkit-keyframes layer-bt-close {
from {
-webkit-transform: translate3d(0, 0, 0);
-ms-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
to {
-webkit-transform: translate3d(0, 100%, 0);
-ms-transform: translate3d(0, 100%, 0);
transform: translate3d(0, 100%, 0);
}
}
@keyframes layer-bt-close {
from {
-webkit-transform: translate3d(0, 0, 0);
-ms-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
to {
-webkit-transform: translate3d(0, 100%, 0);
-ms-transform: translate3d(0, 100%, 0);
transform: translate3d(0, 100%, 0);
}
}
.layer-anim-bt-close,
.layer-anim-bt.layer-anim-close {
-webkit-animation-name: layer-bt-close;
animation-name: layer-bt-close
}

200
web/static/lib/drawer/drawer.js Executable file
View File

@@ -0,0 +1,200 @@
/**
* 抽屉模块
*/
layui.define(['jquery', 'layer'], function (exports) {
('use strict');
var MOD_NAME = 'drawer';
var $ = layui.jquery;
var layer = layui.layer;
layui.link(layui.cache.base + 'drawer/drawer.css');
var drawer = new (function () {
this.open = function (option) {
return layerDrawer(option);
};
this.title = layer.title;
this.style = layer.style;
this.close = layer.close;
this.closeAll = layer.closeAll;
})();
/**
*
* 封装 layer.open
*
* @param {object} option, `type`, `anim`, `move`, `fixed`, `skin`,`maxWidth`, `maxHeight`, `moveOut`, `moveEnd` 不可用,其它参数和 layer.open 一致, 新增 `iframe`和 `url`参数
* @returns {number} 原生 layer 的 index
*/
function layerDrawer(option) {
var opt = normalizeOption(option);
if (opt.target) appendToTarget(opt);
if (opt.url) loadFragment(opt);
if (opt.shade) {
$('<style/>')
.attr('id', 'layer-drawer')
.html('.layui-layer-shade{opacity: 0;transition: opacity .35s cubic-bezier(0.34, 0.69, 0.1, 1);}') // fadeIn
.appendTo('head');
option.end = Aspect(option.end, undefined, function (layero, index) {
$('#layer-drawer').remove();
});
}
return layer.open(opt);
}
/**
* 加载 HTML 片段到 layer content
* @param {object} option 设置选项
*/
function loadFragment(option) {
option.success = Aspect(option.success, function (layero) {
$.ajax({
url: option.url,
dataType: 'html',
success: function (result) {
layero.children('.layui-layer-content').html(result);
},
});
});
}
/**
*将 layer 附加到指定节点
* @param {object} opt 设置选项
*/
function appendToTarget(opt) {
var targetDOM = $(opt.target);
var contentDOM = opt.content;
contentDOM.appendTo(targetDOM);
opt.skin = getDrawerAnimationClass(opt.offset, true);
opt.offset = calcOffset(opt.offset, opt.area, targetDOM);
// 处理关闭后偶现 DOM 仍显示的问题
opt.end = Aspect(opt.end, function () {
contentDOM.css('display', 'none');
});
if (opt.shade) {
opt.success = Aspect(opt.success, function (layero, index) {
var shadeDOM = $('#layui-layer-shade' + index);
shadeDOM.css('position', 'absolute');
shadeDOM.appendTo(layero.parent());
});
}
}
/**
* 规范化 layer.open 选项
* @param {object} option layer.open 的选项
* @returns 规范化后的选项
*/
function normalizeOption(option) {
option.type = option.iframe ? 2 : 1;
option.anim = -1;
option.move = false;
option.fixed = true;
option.content = option.iframe ? option.iframe : option.content;
if (option.offset === undefined) option.offset = 'r';
option.area = calcDrawerArea(option.offset, option.area);
option.skin = getDrawerAnimationClass(option.offset);
if (option.title === undefined) option.title = false;
if (option.closeBtn === undefined) option.closeBtn = false;
if (option.shade === undefined) option.shade = 0.3;
if (option.shadeClose === undefined) option.shadeClose = true;
if (option.resize === undefined) option.resize = false;
if (option.success === undefined) option.success = function () {}; // 处理遮罩需要
if (option.end === undefined) option.end = function () {};
return option;
}
/**
* 计算抽屉宽高
* @param {string} offset 抽屉方向 l = 左, r = 右, t = 上, b = 下
* @param {string[] | string} drawerArea 抽屉大小,字符串数组格式[width, height]["200px","100%"],字符串格式:"30%" "200px"。
* @returns{string[]} 抽屉宽高数组
*/
function calcDrawerArea(offset, drawerArea) {
if (drawerArea instanceof Array) {
return drawerArea;
}
drawerArea = drawerArea === undefined || drawerArea === 'auto' ? '30%' : drawerArea;
if (offset === 'l' || offset === 'r') {
return [drawerArea, '100%'];
} else if (offset === 't' || offset === 'b') {
return ['100%', drawerArea];
}
}
/**
* 获取抽屉动画类,指定挂载容器时需要设置绝对定位
* @param {string} offset 抽屉方向 l = 左, r = 右, t = 上, b = 下
* @param {boolean} [isAbsolute] 是否是绝对定位
* @returns {string} 抽屉入场动画类
*/
function getDrawerAnimationClass(offset, isAbsolute) {
var prefixClass = 'layer-drawer layer-drawer-anim layui-anim layer-anim-';
var suffix = 'rl';
if (offset === 'l') {
suffix = 'lr';
} else if (offset === 'r') {
suffix = 'rl';
} else if (offset === 't') {
suffix = 'tb';
} else if (offset === 'b') {
suffix = 'bt';
}
return prefixClass + suffix + (isAbsolute ? ' position-absolute ' : '');
}
/**
* 指定挂载容器重新计算 offset
*
* layer 源码中使用窗口宽高计算位置,所以此
* @param {string} offset 位置
* @param {string | string[]} area 范围大小
* @param {*} targetEl 挂载节点
* @returns 包含抽屉位置信息的数组,[top, left]
*/
function calcOffset(offset, area, targetEl) {
// https://gitee.com/layui/layui/blob/main/src/modules/layer.js#L560
if (offset === undefined || offset === 'l' || offset === 't') {
offset = 'lt';
} else if (offset === 'r') {
// https://gitee.com/layui/layui/blob/main/src/modules/layer.js#L554
area = area instanceof Array ? area[0] : area;
var left = /%$/.test(area)
? targetEl.innerWidth() * (1 - window.parseFloat(area) / 100)
: targetEl.innerWidth() - window.parseFloat(area);
offset = ['0', left];
} else if (offset === 'b') {
area = area instanceof Array ? area[1] : area;
var top = /%$/.test(area)
? targetEl.innerHeight() * (1 - window.parseFloat(area) / 100)
: targetEl.innerHeight() - window.parseFloat(area);
offset = [top, '0'];
}
return offset;
}
/**
* 简易的切面
* @param {Function} target 被通知的对象,原函数
* @param {Function | undefined} [before] 前置通知
* @param {Function | undefined} [after] 后置通知
* @returns 代理函数
*/
function Aspect(target, before, after) {
function proxyFunc() {
if (before && typeof before === 'function') {
before.apply(this, arguments);
}
target.apply(this, arguments);
if (after && typeof after === 'function') {
after.apply(this, arguments);
}
}
return proxyFunc;
}
exports(MOD_NAME, drawer);
});

142
web/static/lib/include.js Executable file
View File

@@ -0,0 +1,142 @@
/**
* @typedef {object} IncludeFile
*
* @prop {boolean} ok
* @prop {number} status
* @prop {string} html
*/
/** @type {Map<string,IncludeFile | Promise<IncludeFile>>} */
const includeFiles = new Map();
/**
*
* @param {string} src
* @param {'cors' | 'no-cors' | 'same-origin'} [mode='cors']
*
* @returns {Promise<IncludeFile>}
*/
export function requestInclude(src, mode = 'cors'){
const prev = includeFiles.get(src);
if (prev !== undefined) {
return Promise.resolve(prev);
}
const fileDataPromise = fetch(src, { mode: mode }).then(async response => {
const res = {
ok: response.ok,
status: response.status,
html: await response.text()
};
includeFiles.set(src, res);
return res;
});
includeFiles.set(src, fileDataPromise);
return fileDataPromise;
}
class HtmlImport extends HTMLElement {
constructor () {
super();
}
static get observedAttributes () {
return ['src', 'mode', 'allow-scripts'];
}
get src() {
return this.getAttribute('src') || '';
}
set src(value) {
this.setAttribute('src', value);
}
get mode() {
return this.getAttribute('mode') || 'cors';
}
set mode(value) {
this.setAttribute('mode', value);
}
get allowScripts() {
return this.hasAttribute('allow-scripts');
}
set allowScripts(value) {
this.toggleAttribute('allow-scripts', value);
}
/**
* 执行 innerHTML 中的 <script></script>
* @param {HTMLScriptElement} scripts
*/
async executeScript(scripts) {
const execQueue = function (script) {
const newScript = document.createElement('script');
[...script.attributes].forEach(attr => newScript.setAttribute(attr.name, attr.value));
newScript.textContent = script.textContent;
script.parentNode && script.parentNode.replaceChild(newScript, script);
return script.src ? new Promise((resolve) => {
newScript.async = false;
newScript.addEventListener('load', e => resolve(e));
newScript.addEventListener('error', e => resolve(e));
}) : Promise.resolve();
};
// 按 <script> 顺序执行,确保上下文关联
for (const script of scripts) {
await execQueue(script);
// console.log(`${script.src||script} loaded`, Date.now());
}
}
async handleSrcChange() {
try {
const src = this.src;
const file = await requestInclude(src, this.mode);
if (src !== this.src) {
return;
}
if (!file.ok) {
this.emit('error', { detail: { status: file.status } });
return;
}
this.innerHTML = file.html;
if (this.allowScripts) {
await this.executeScript(this.querySelectorAll('script'));
}
this.emit('load');
} catch {
this.emit('error', { detail: { status: -1 } });
}
}
attributeChangedCallback (name) {
if (name == 'src') {
this.handleSrcChange();
}
}
emit(name, options) {
const event = new CustomEvent(name, {
bubbles: true,
cancelable: false,
composed: true,
detail: {},
...options
});
this.dispatchEvent(event);
return event;
}
}
if (!customElements.get('wc-include')) {
customElements.define('wc-include', HtmlImport);
}

11360
web/static/lib/less.js Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,202 @@
:root{
/* =====色板===== */
/*常量,不随明暗主题变化*/
--color-white: #FFFFFF;
--color-black: #000000;
--lay-color-white: #FAFAFA;
--lay-color-black: #333333;
--lay-color-red-1: #FFF1E8;
--lay-color-red-2: #FFD7C0;
--lay-color-red-3: #FFBB99;
--lay-color-red-4: #FF9C71;
--lay-color-red-5: #FF7A4A;
--lay-color-red-6: #FF5722;
--lay-color-red-7: #D23B15;
--lay-color-red-8: #A6250B;
--lay-color-red-9: #791404;
--lay-color-red-10: #4D0800;
--lay-color-blue-1: #E8F9FF;
--lay-color-blue-2: #C0ECFF;
--lay-color-blue-3: #97DCFF;
--lay-color-blue-4: #6FCAFF;
--lay-color-blue-5: #46B5FF;
--lay-color-blue-6: #1E9FFF;
--lay-color-blue-7: #1379D2;
--lay-color-blue-8: #0A58A6;
--lay-color-blue-9: #043A79;
--lay-color-blue-10: #00214D;
--lay-color-lightblue-1: #E8FDFF;
--lay-color-lightblue-2: #C1F4FB;
--lay-color-lightblue-3: #9CEAF7;
--lay-color-lightblue-4: #77DDF4;
--lay-color-lightblue-5: #53CEF0;
--lay-color-lightblue-6: #31BDEC;
--lay-color-lightblue-7: #1F95C4;
--lay-color-lightblue-8: #10709C;
--lay-color-lightblue-9: #064E74;
--lay-color-lightblue-10: #002F4D;
--lay-color-layuigreen-1: #E8FFF9;
--lay-color-layuigreen-2: #B5F1E3;
--lay-color-layuigreen-3: #87E3D1;
--lay-color-layuigreen-4: #5DD6C1;
--lay-color-layuigreen-5: #37C8B5;
--lay-color-layuigreen-6: #16BAAA;
--lay-color-layuigreen-7: #0E9F95;
--lay-color-layuigreen-8: #08837F;
--lay-color-layuigreen-9: #036868;
--lay-color-layuigreen-10: #004A4D;
--lay-color-green-1: #E8FFF2;
--lay-color-green-2: #B5F1D1;
--lay-color-green-3: #86E2B4;
--lay-color-green-4: #5CD49C;
--lay-color-green-5: #37C588;
--lay-color-green-6: #16B777;
--lay-color-green-7: #0E9C68;
--lay-color-green-8: #088259;
--lay-color-green-9: #036749;
--lay-color-green-10: #004D38;
--lay-color-orange-1: #FFFCE8;
--lay-color-orange-2: #FFF5BA;
--lay-color-orange-3: #FFEA8B;
--lay-color-orange-4: #FFDC5D;
--lay-color-orange-5: #FFCB2E;
--lay-color-orange-6: #FFB800;
--lay-color-orange-7: #D29000;
--lay-color-orange-8: #A66C00;
--lay-color-orange-9: #794B00;
--lay-color-orange-10: #4D2D00;
--lay-color-cyan-1: #E8F6FF;
--lay-color-cyan-2: #B9CEDD;
--lay-color-cyan-3: #8FA7BB;
--lay-color-cyan-4: #6A829A;
--lay-color-cyan-5: #4A5F78;
--lay-color-cyan-6: #2F4056;
--lay-color-cyan-7: #223654;
--lay-color-cyan-8: #162C51;
--lay-color-cyan-9: #0B214F;
--lay-color-cyan-10: #00174D;
--lay-color-purple-1: #FDE8FF;
--lay-color-purple-2: #EDBEF4;
--lay-color-purple-3: #DC97E8;
--lay-color-purple-4: #C972DD;
--lay-color-purple-5: #B651D1;
--lay-color-purple-6: #A233C6;
--lay-color-purple-7: #8120A8;
--lay-color-purple-8: #631289;
--lay-color-purple-9: #48076B;
--lay-color-purple-10: #2F004D;
--lay-color-black-1: #E8F8FF;
--lay-color-black-2: #BFD0D8;
--lay-color-black-3: #98A8B1;
--lay-color-black-4: #73818A;
--lay-color-black-5: #505B63;
--lay-color-black-6: #2F363C;
--lay-color-black-7: #23303C;
--lay-color-black-8: #18293C;
--lay-color-black-9: #0C213C;
--lay-color-black-10: #00183C;
--lay-color-gray-1: #FAFAFA;
--lay-color-gray-2: #F6F6F6;
--lay-color-gray-3: #EEEEEE;
--lay-color-gray-4: #E2E2E2;
--lay-color-gray-5: #DDDDDD;
--lay-color-gray-6: #D2D2D2;
--lay-color-gray-7: #CCCCCC;
--lay-color-gray-8: #C2C2C2;
--lay-color-gray-9: #AAAAAA;
--lay-color-gray-10: #939393;
--lay-color-gray-11: #858585;
--lay-color-gray-12: #7b7b7b;
--lay-color-gray-13: #686868;
/* =====语义===== */
/* 主色 */
--lay-color-primary: var(--lay-color-layuigreen-6);
--lay-color-primary-hover: var(--lay-color-layuigreen-5);
--lay-color-primary-active: var(--lay-color-layuigreen-7);
--lay-color-primary-disabled: var(--lay-color-layuigreen-3);
--lay-color-primary-light: var(--lay-color-layuigreen-4);
/* 次色 */
--lay-color-secondary: var(--lay-color-green-6);
--lay-color-secondary-hover: var(--lay-color-green-5);
--lay-color-secondary-active: var(--lay-color-green-7);
--lay-color-secondary-disabled: var(--lay-color-green-3);
--lay-color-secondary-light: var(--lay-color-green-4);
/* 引导 */
--lay-color-info: var(--lay-color-lightblue-6);
--lay-color-info-hover: var(--lay-color-lightblue-5);
--lay-color-info-active: var(--lay-color-lightblue-7);
--lay-color-info-disabled: var(--lay-color-lightblue-3);
--lay-color-info-light: var(--lay-color-lightblue-4);
/* 百搭 */
--lay-color-normal: var(--lay-color-blue-6);
--lay-color-normal-hover: var(--lay-color-blue-5);
--lay-color-normal-active: var(--lay-color-blue-7);
--lay-color-normal-disabled: var(--lay-color-blue-3);
--lay-color-normal-light: var(--lay-color-blue-4);
/* 警示 */
--lay-color-warning: var(--lay-color-orange-6);
--lay-color-warning-hover: var(--lay-color-orange-5);
--lay-color-warning-active: var(--lay-color-orange-7);
--lay-color-warning-disabled: var(--lay-color-orange-3);
--lay-color-warning-light: var(--lay-color-orange-4);
/* 成功 */
--lay-color-success: var(--lay-color-green-6);
--lay-color-success-hover: var(--lay-color-green-5);
--lay-color-success-active: var(--lay-color-green-7);
--lay-color-success-disabled: var(--lay-color-green-3);
--lay-color-success-light: var(--lay-color-green-4);
/* 错误 */
--lay-color-danger: var(--lay-color-red-6);
--lay-color-danger-hover: var(--lay-color-red-5);
--lay-color-danger-active: var(--lay-color-red-7);
--lay-color-danger-disabled: var(--lay-color-red-3);
--lay-color-danger-light: var(--lay-color-red-4);
--lay-color-bg-1: #17171A; /*整体背景*/
--lay-color-bg-2: #232324; /*一级容器背景,卡片,面板*/
--lay-color-bg-3: #2a2a2b; /*二级容器背景*/
--lay-color-bg-4: #313132; /*三级容器背景*/
--lay-color-bg-5: #373739; /*下拉弹出框、Tooltip 背景颜色*/
--lay-color-bg-white: #f6f6f6; /*白色背景*/
--lay-color-text-1: rgba(255,255,255,.9); /*强调/正文标题*/
--lay-color-text-2: rgba(255,255,255,.7); /*次强调/语句*/
--lay-color-text-3: rgba(255,255,255,.5); /*次要信息*/
--lay-color-text-4: rgba(255,255,255,.3);/*禁用状态文字 */
--lay-color-border-1: #2e2e30;
--lay-color-border-2: #484849;
--lay-color-border-3: #5f5f60;
--lay-color-border-4: #929293;
--lay-color-fill-1: rgba(255,255,255,.04);/*浅/禁用*/
--lay-color-fill-2: rgba(255,255,255,.08);/*常规/白底悬浮*/
--lay-color-fill-3: rgba(255,255,255,.12); /*深/灰底悬浮*/
--lay-color-fill-4: rgba(255,255,255,.16);/*重/特殊场景*/
--lay-color-hover: var(--lay-color-fill-3); /*bg*/
--lay-color-active: var(--lay-color-fill-3); /*bg*/
--lay-shadow-1: 0 4px 6px rgba(0, 0, 0, 6%), 0 1px 10px rgba(0, 0, 0, 8%), 0 2px 4px rgba(0, 0, 0, 12%);/*基础/下层投影 卡片面板*/
--lay-shadow-2: 0 8px 10px rgba(0, 0, 0, 12%), 0 3px 14px rgba(0, 0, 0, 10%), 0 5px 5px rgba(0, 0, 0, 16%);/*中层投影 下拉菜单,选择器*/
--lay-shadow-3: 0 16px 24px rgba(0, 0, 0, 14%), 0 6px 30px rgba(0, 0, 0, 12%), 0 8px 10px rgba(0, 0, 0, 20%);/*上层投影 弹窗*/
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,738 @@
:root{
/* =====色板===== */
/*常量,不随明暗主题变化*/
--color-white: #FFFFFF;
--color-black: #000000;
--lay-color-white: #FAFAFA;
--lay-color-black: #333333;
--lay-color-red-1: #FFF1E8;
--lay-color-red-2: #FFD7C0;
--lay-color-red-3: #FFBB99;
--lay-color-red-4: #FF9C71;
--lay-color-red-5: #FF7A4A;
--lay-color-red-6: #FF5722;
--lay-color-red-7: #D23B15;
--lay-color-red-8: #A6250B;
--lay-color-red-9: #791404;
--lay-color-red-10: #4D0800;
--lay-color-blue-1: #E8F9FF;
--lay-color-blue-2: #C0ECFF;
--lay-color-blue-3: #97DCFF;
--lay-color-blue-4: #6FCAFF;
--lay-color-blue-5: #46B5FF;
--lay-color-blue-6: #1E9FFF;
--lay-color-blue-7: #1379D2;
--lay-color-blue-8: #0A58A6;
--lay-color-blue-9: #043A79;
--lay-color-blue-10: #00214D;
--lay-color-lightblue-1: #E8FDFF;
--lay-color-lightblue-2: #C1F4FB;
--lay-color-lightblue-3: #9CEAF7;
--lay-color-lightblue-4: #77DDF4;
--lay-color-lightblue-5: #53CEF0;
--lay-color-lightblue-6: #31BDEC;
--lay-color-lightblue-7: #1F95C4;
--lay-color-lightblue-8: #10709C;
--lay-color-lightblue-9: #064E74;
--lay-color-lightblue-10: #002F4D;
--lay-color-layuigreen-1: #E8FFF9;
--lay-color-layuigreen-2: #B5F1E3;
--lay-color-layuigreen-3: #87E3D1;
--lay-color-layuigreen-4: #5DD6C1;
--lay-color-layuigreen-5: #37C8B5;
--lay-color-layuigreen-6: #16BAAA;
--lay-color-layuigreen-7: #0E9F95;
--lay-color-layuigreen-8: #08837F;
--lay-color-layuigreen-9: #036868;
--lay-color-layuigreen-10: #004A4D;
--lay-color-green-1: #E8FFF2;
--lay-color-green-2: #B5F1D1;
--lay-color-green-3: #86E2B4;
--lay-color-green-4: #5CD49C;
--lay-color-green-5: #37C588;
--lay-color-green-6: #16B777;
--lay-color-green-7: #0E9C68;
--lay-color-green-8: #088259;
--lay-color-green-9: #036749;
--lay-color-green-10: #004D38;
--lay-color-orange-1: #FFFCE8;
--lay-color-orange-2: #FFF5BA;
--lay-color-orange-3: #FFEA8B;
--lay-color-orange-4: #FFDC5D;
--lay-color-orange-5: #FFCB2E;
--lay-color-orange-6: #FFB800;
--lay-color-orange-7: #D29000;
--lay-color-orange-8: #A66C00;
--lay-color-orange-9: #794B00;
--lay-color-orange-10: #4D2D00;
--lay-color-cyan-1: #E8F6FF;
--lay-color-cyan-2: #B9CEDD;
--lay-color-cyan-3: #8FA7BB;
--lay-color-cyan-4: #6A829A;
--lay-color-cyan-5: #4A5F78;
--lay-color-cyan-6: #2F4056;
--lay-color-cyan-7: #223654;
--lay-color-cyan-8: #162C51;
--lay-color-cyan-9: #0B214F;
--lay-color-cyan-10: #00174D;
--lay-color-purple-1: #FDE8FF;
--lay-color-purple-2: #EDBEF4;
--lay-color-purple-3: #DC97E8;
--lay-color-purple-4: #C972DD;
--lay-color-purple-5: #B651D1;
--lay-color-purple-6: #A233C6;
--lay-color-purple-7: #8120A8;
--lay-color-purple-8: #631289;
--lay-color-purple-9: #48076B;
--lay-color-purple-10: #2F004D;
--lay-color-black-1: #E8F8FF;
--lay-color-black-2: #BFD0D8;
--lay-color-black-3: #98A8B1;
--lay-color-black-4: #73818A;
--lay-color-black-5: #505B63;
--lay-color-black-6: #2F363C;
--lay-color-black-7: #23303C;
--lay-color-black-8: #18293C;
--lay-color-black-9: #0C213C;
--lay-color-black-10: #00183C;
--lay-color-gray-1: #FAFAFA;
--lay-color-gray-2: #F6F6F6;
--lay-color-gray-3: #EEEEEE;
--lay-color-gray-4: #E2E2E2;
--lay-color-gray-5: #DDDDDD;
--lay-color-gray-6: #D2D2D2;
--lay-color-gray-7: #CCCCCC;
--lay-color-gray-8: #C2C2C2;
--lay-color-gray-9: #AAAAAA;
--lay-color-gray-10: #939393;
--lay-color-gray-11: #858585;
--lay-color-gray-12: #7b7b7b;
--lay-color-gray-13: #686868;
/* =====语义===== */
/* 主色 */
--lay-color-primary: var(--lay-color-layuigreen-6);
--lay-color-primary-hover: var(--lay-color-layuigreen-5);
--lay-color-primary-active: var(--lay-color-layuigreen-7);
--lay-color-primary-disabled: var(--lay-color-layuigreen-3);
--lay-color-primary-light: var(--lay-color-layuigreen-4);
/* 次色 */
--lay-color-secondary: var(--lay-color-green-6);
--lay-color-secondary-hover: var(--lay-color-green-5);
--lay-color-secondary-active: var(--lay-color-green-7);
--lay-color-secondary-disabled: var(--lay-color-green-3);
--lay-color-secondary-light: var(--lay-color-green-4);
/* 引导 */
--lay-color-info: var(--lay-color-lightblue-6);
--lay-color-info-hover: var(--lay-color-lightblue-5);
--lay-color-info-active: var(--lay-color-lightblue-7);
--lay-color-info-disabled: var(--lay-color-lightblue-3);
--lay-color-info-light: var(--lay-color-lightblue-4);
/* 百搭 */
--lay-color-normal: var(--lay-color-blue-6);
--lay-color-normal-hover: var(--lay-color-blue-5);
--lay-color-normal-active: var(--lay-color-blue-7);
--lay-color-normal-disabled: var(--lay-color-blue-3);
--lay-color-normal-light: var(--lay-color-blue-4);
/* 警示 */
--lay-color-warning: var(--lay-color-orange-6);
--lay-color-warning-hover: var(--lay-color-orange-5);
--lay-color-warning-active: var(--lay-color-orange-7);
--lay-color-warning-disabled: var(--lay-color-orange-3);
--lay-color-warning-light: var(--lay-color-orange-4);
/* 成功 */
--lay-color-success: var(--lay-color-green-6);
--lay-color-success-hover: var(--lay-color-green-5);
--lay-color-success-active: var(--lay-color-green-7);
--lay-color-success-disabled: var(--lay-color-green-3);
--lay-color-success-light: var(--lay-color-green-4);
/* 错误 */
--lay-color-danger: var(--lay-color-red-6);
--lay-color-danger-hover: var(--lay-color-red-5);
--lay-color-danger-active: var(--lay-color-red-7);
--lay-color-danger-disabled: var(--lay-color-red-3);
--lay-color-danger-light: var(--lay-color-red-4);
--lay-color-bg-1: #17171A; /*整体背景*/
--lay-color-bg-2: #232324; /*一级容器背景,卡片,面板*/
--lay-color-bg-3: #2a2a2b; /*二级容器背景*/
--lay-color-bg-4: #313132; /*三级容器背景*/
--lay-color-bg-5: #373739; /*下拉弹出框、Tooltip 背景颜色*/
--lay-color-bg-white: #f6f6f6; /*白色背景*/
--lay-color-text-1: rgba(255,255,255,.9); /*强调/正文标题*/
--lay-color-text-2: rgba(255,255,255,.7); /*次强调/语句*/
--lay-color-text-3: rgba(255,255,255,.5); /*次要信息*/
--lay-color-text-4: rgba(255,255,255,.3);/*禁用状态文字 */
--lay-color-border-1: #2e2e30;
--lay-color-border-2: #484849;
--lay-color-border-3: #5f5f60;
--lay-color-border-4: #929293;
--lay-color-fill-1: rgba(255,255,255,.04);/*浅/禁用*/
--lay-color-fill-2: rgba(255,255,255,.08);/*常规/白底悬浮*/
--lay-color-fill-3: rgba(255,255,255,.12); /*深/灰底悬浮*/
--lay-color-fill-4: rgba(255,255,255,.16);/*重/特殊场景*/
--lay-color-hover: var(--lay-color-fill-3); /*bg*/
--lay-color-active: var(--lay-color-fill-3); /*bg*/
--lay-shadow-1: 0 4px 6px rgba(0, 0, 0, 6%), 0 1px 10px rgba(0, 0, 0, 8%), 0 2px 4px rgba(0, 0, 0, 12%);/*基础/下层投影 卡片面板*/
--lay-shadow-2: 0 8px 10px rgba(0, 0, 0, 12%), 0 3px 14px rgba(0, 0, 0, 10%), 0 5px 5px rgba(0, 0, 0, 16%);/*中层投影 下拉菜单,选择器*/
--lay-shadow-3: 0 16px 24px rgba(0, 0, 0, 14%), 0 6px 30px rgba(0, 0, 0, 12%), 0 8px 10px rgba(0, 0, 0, 20%);/*上层投影 弹窗*/
}
blockquote,body,button,dd,div,dl,dt,form,h1,h2,h3,h4,h5,h6,input,li,ol,p,pre,td,textarea,th,ul{-webkit-tap-highlight-color: rgba(0, 0, 0, 0)} /*danger: 勿改*/
body{color:var(--lay-color-text-2);background-color: var(--lay-color-bg-1); color-scheme: dark;}
hr{border-bottom:1px solid var(--lay-color-border-2)!important}
a{color:var(--lay-color-text-1);}
a:hover{color:var(--lay-color-text-3)}
/* 三角形 */
.layui-edge{border-color:transparent}
.layui-edge-top{border-bottom-color:var(--lay-color-border-4)}
.layui-edge-right{border-left-color:var(--lay-color-border-4)}
.layui-edge-bottom{border-top-color:var(--lay-color-border-4)}
.layui-edge-left{border-right-color:var(--lay-color-border-4)}
/* 禁用文字 */
.layui-disabled,.layui-disabled:hover{color:var(--lay-color-text-4)!important}
/* 图标 */
.layui-icon{-moz-osx-font-smoothing:grayscale}
/* admin 布局 */
.layui-layout-admin .layui-header{background-color:var(--lay-color-bg-2)}
.layui-layout-admin .layui-footer{box-shadow:-1px 0 4px rgb(0 0 0 / 12%);background-color:var(--lay-color-bg-2)}
.layui-layout-admin .layui-logo{color:var(--lay-color-primary);box-shadow:0 1px 2px 0 rgb(0 0 0 / 15%)}
/* 引用 */
.layui-elem-quote{border-left:5px solid var(--lay-color-secondary);background-color:var(--lay-color-fill-1)}
.layui-quote-nm{border-color: var(--lay-color-fill-1)}
/* 进度条 */
.layui-progress{background-color: var(--lay-color-bg-3)}
.layui-progress-bar{background-color:var( --lay-color-secondary)}
.layui-progress-text{color:var(--lay-color-text-2)}
.layui-progress-big .layui-progress-text{color: var(--lay-color-text-1)}
/* 折叠面板 */
.layui-colla-title{color: var(--lay-color-text-1);background-color: var(--lay-color-bg-2)}
.layui-colla-content{color:var(--lay-color-text-2)}
/* 卡片面板 */
.layui-card{background-color: var(--lay-color-bg-2);box-shadow:var(--lay-shadow-1)}
.layui-card-header{border-bottom:1px solid var(--lay-color-border-2);color:var(--lay-color-text-1)}
/* 常规面板 */
.layui-panel{box-shadow:var(--lay-shadow-1);background-color: var( --lay-color-bg-2);color: var(--lay-color-text-1)}
.layui-menu-body-panel{box-shadow: var(--lay-shadow-2)}
/* 窗口面板 */
.layui-panel-window{border-top:5px solid var(--lay-color-border-2);background-color: var(--lay-color-bg-2)}
/* 背景颜色 */
.layui-bg-red{background-color:var(--lay-color-red-6)!important;color: var(--lay-color-white)!important}
.layui-bg-orange{background-color:var(--lay-color-orange-6)!important;color: var(--lay-color-white)!important}
.layui-bg-green{background-color:var(--lay-color-layuigreen-6)!important;color: var(--lay-color-white)!important}
.layui-bg-cyan{background-color:var(--lay-color-cyan-6)!important;color: var(--lay-color-white)!important}
.layui-bg-blue{background-color: var(--lay-color-blue-6)!important;color: var(--lay-color-white)!important}
.layui-bg-black{background-color:var(--lay-color-black-6)!important;color: var(--lay-color-white)!important}
.layui-bg-purple{background-color: var(--lay-color-purple-6)!important; color: var(--lay-color-white)!important;}
.layui-bg-gray{background-color:var(--lay-color-gray-1)!important;color: var(--lay-color-black-6)!important}
/* 徽章 */
.layui-badge-rim,.layui-border,.layui-colla-content,.layui-colla-item,.layui-collapse,.layui-elem-field,.layui-form-pane .layui-form-item[pane],.layui-form-pane .layui-form-label,.layui-input,.layui-input-split,.layui-panel,.layui-select,.layui-tab-bar,.layui-tab-card,.layui-tab-title,.layui-tab-title .layui-this:after,.layui-textarea{border-color: var(--lay-color-border-1)}
/* 边框颜色 */
.layui-border{color:var(--lay-color-text-1)!important}
.layui-border-red{border-color:var(--lay-color-red-6)!important;color:var(--lay-color-red-6)!important}
.layui-border-orange{border-color:var(--lay-color-orange-6)!important;color:var(--lay-color-orange-6)!important}
.layui-border-green{border-color:var(--lay-color-layuigreen-6)!important;color:var(--lay-color-layuigreen-6)!important}
.layui-border-cyan{border-color:var(--lay-color-cyan-6)!important;color:var(--lay-color-cyan-6)!important}
.layui-border-blue{border-color: var(--lay-color-blue-6)!important;color: var(--lay-color-blue-6)!important}
.layui-border-purple{border-color: var(--lay-color-purple-6)!important; color: var(--lay-color-purple-6)!important;}
.layui-border-black{border-color:var(--lay-color-black-6)!important;color:var(--lay-color-text-1)!important}
/* 文本区域 */
.layui-text{color:var(--lay-color-text-2)}
.layui-text-em,.layui-word-aux{color: var(--lay-color-text-3)!important}
.layui-text a:not(.layui-btn){color:var(--lay-color-lightblue-6)}
.layui-text blockquote:not(.layui-elem-quote){border-left:5px solid var(--lay-color-border-4)}
/* 字体颜色 */
.layui-font-red{color:var(--lay-color-red-6)!important}
.layui-font-orange{color:var(--lay-color-orange-6)!important}
.layui-font-green{color:var(--lay-color-layuigreen-6)!important}
.layui-font-cyan{color:var(--lay-color-cyan-6)!important}
.layui-font-blue{color:var(--lay-color-lightblue-6)!important}
.layui-font-black{color:var(--lay-color-black)!important}
.layui-font-purple{color:var(--lay-color-purple-6)!important;}
.layui-font-gray{color:var(--lay-color-gray-7)!important}
/* 按钮 */
.layui-btn{border:1px solid transparent;background-color:var(--lay-color-primary);color: var(--lay-color-text-1)}
.layui-btn:hover{color: var(--lay-color-text-2)}
.layui-btn-primary{border-color:var(--lay-color-border-2);color:var(--lay-color-text-1);background-color: var(--lay-color-bg-4)}
.layui-btn-primary:hover{border-color: transparent;color:var(--lay-color-text-2)}
.layui-btn-normal{background-color: var(--lay-color-normal)}
.layui-btn-warm{background-color:var(--lay-color-warning)}
.layui-btn-danger{background-color:var(--lay-color-danger)}
.layui-btn-checked{background-color:var(--lay-color-success)}
.layui-btn-disabled,.layui-btn-disabled:active,.layui-btn-disabled:hover{border-color: var(--lay-color-border-2)!important;background-color: var(--lay-color-bg-2)!important;color: var(--lay-color-text-4)!important}
.layui-btn-group .layui-btn{border-left:1px solid var(--lay-color-border-2)}
.layui-btn-group .layui-btn-primary:hover{border-color:var(--lay-color-border-2);color:var(--lay-color-primary)}
.layui-btn-group .layui-btn-primary:first-child{border-left:1px solid var(--lay-color-gray-5)}
/*表单*/
.layui-input,.layui-select,.layui-textarea{background-color: var(--lay-color-fill-2);color: var(--lay-color-text-2)}
.layui-input:hover,.layui-textarea:hover{border-color: var(--lay-color-border-2)!important}
.layui-input:focus,.layui-textarea:focus{border-color: var(--lay-color-secondary-hover)!important;background-color: var(--lay-color-bg-2);box-shadow: 0 0 0 3px rgba(22, 183, 119, 0.08);}
.layui-input[disabled],.layui-select[disabled],.layui-textarea[disabled],.layui-input.layui-disabled,.layui-textarea.layui-disabled{background-color: var(--lay-color-fill-1);color: var(--lay-color-text-4);border-color: var(--lay-color-border-1)!important;box-shadow: 0 0 0 0;}
.layui-form-danger+.layui-form-select .layui-input,.layui-form-danger:focus{border-color:var(--lay-color-danger)!important;box-shadow: 0 0 0 3px rgba(255, 87, 34, 0.08);}
/* 输入框点缀 */
.layui-input-prefix .layui-icon,.layui-input-split .layui-icon,.layui-input-suffix .layui-icon{color: var(--lay-color-gray-8)}
.layui-input-wrap .layui-input:hover+.layui-input-split{border-color: var(--lay-color-border-2)}
.layui-input-wrap .layui-input[disabled]:hover+.layui-input-split{border-color: var(--lay-color-border-1)}
.layui-input-wrap .layui-input:focus+.layui-input-split{border-color: var(--lay-color-secondary-hover)}
.layui-input-wrap .layui-input.layui-form-danger:focus + .layui-input-split{border-color: var(--lay-color-danger);}
.layui-input-affix .layui-icon{color: var(--lay-color-text-2)}
.layui-input-affix .layui-icon-clear{color:var(--lay-color-text-2)}
.layui-input-affix .layui-icon:hover{color:var(--lay-color-text-3)}
/* 数字输入框动态点缀 */
.layui-input-wrap .layui-input-number .layui-icon-up{border-bottom-color:var(--lay-color-border-1)}
.layui-input-wrap .layui-input[type="number"].layui-input-number-out-of-range{color:var(--lay-color-danger)}
/* 下拉选择 */
.layui-form-select{color:var(--lay-color-text-2)}
.layui-form-select .layui-edge{border-top-color:var(--lay-color-gray-8)}
.layui-form-select dl{border:1px solid var( --lay-color-border-2);background-color: var(--lay-color-bg-5);box-shadow:var(--lay-shadow-2)}
.layui-form-select dl dt{color:var(--lay-color-gray-8)}
.layui-form-select dl dd:hover{background-color:var(--lay-color-active)}
.layui-form-select dl dd.layui-select-tips{color:var(--lay-color-text-2)}
.layui-form-select dl dd.layui-this{background-color: var(--lay-color-active);color: var(--lay-color-text-1)}
.layui-form-select dl dd.layui-disabled,.layui-form-select dl dd:hover.layui-disabled{background-color: var(--lay-color-bg-5)}
.layui-select-none{color:var(--lay-color-black-8)}
.layui-select-disabled .layui-disabled{border-color:var(--lay-color-border-1)!important}
.layui-select-disabled .layui-edge{border-top-color:var(--lay-color-gray-6)}
/* 复选框 */
.layui-form-checkbox{background-color:var(--lay-color-fill-2)}
.layui-form-checkbox>div{background-color:var(--lay-color-fill-3);color:var(--lay-color-text-2)}
.layui-form-checkbox:hover>div{background-color: var(--lay-color-active)}
.layui-form-checkbox>i{background-color: var(--lay-color-fill-1);border-top-color:var(--lay-color-border-1);border-right-color:var(--lay-color-border-1);border-bottom-color:var(--lay-color-border-1);border-left-color:initial;color:var(--lay-color-text-1)}
.layui-form-checkbox:hover>i{border-color:var(--lay-color-border-2);color:var(--lay-color-text-4)}
.layui-form-checked,.layui-form-checked:hover{border-color:var(--lay-color-secondary-active)}
.layui-form-checked>div,.layui-form-checked:hover>div{background-color:var(--lay-color-secondary)}
.layui-form-checked>i,.layui-form-checked:hover>i{color:var(--lay-color-secondary-hover)}
.layui-form-checkbox.layui-checkbox-disabled>div{background-color: var(--lay-color-fill-3) !important;}
/* 复选框-默认风格 */
.layui-form-checkbox[lay-skin=primary]{background-image:none;background-color:initial;border-color:initial!important}
.layui-form-checkbox[lay-skin=primary]>div{background-image:none;background-color:initial;color:var(--lay-color-text-2)}
.layui-form-checkbox[lay-skin=primary]>i{border-color:var(--lay-color-border-1);background-color:var(--lay-color-fill-2)}
.layui-form-checkbox[lay-skin=primary]:hover>i{border-color:var(--lay-color-secondary-hover);color:var(--lay-color-text-1)}
.layui-form-checked[lay-skin=primary]>i{background-color:var(--lay-color-secondary);color:var(--lay-color-text-1);border-color:var(--lay-color-secondary-active)!important}
.layui-checkbox-disabled[lay-skin=primary] >div{background:none!important;color:var(--lay-color-text-4)!important}
.layui-form-checked.layui-checkbox-disabled[lay-skin=primary]>i{background-color:var(--lay-color-fill-1)!important;border-color:var(--lay-color-border-2)!important}
.layui-checkbox-disabled[lay-skin=primary]:hover>i{border-color:var(--lay-color-border-1)}
.layui-form-checkbox[lay-skin="primary"]>.layui-icon-indeterminate:before{background-color: var(--lay-color-secondary-hover);opacity: 1;}
.layui-form-checkbox[lay-skin="primary"]:hover>.layui-icon-indeterminate:before{opacity: 1;}
.layui-form-checkbox[lay-skin="primary"]>.layui-icon-indeterminate{border-color: var(--lay-color-secondary-hover);}
/* 复选框-开关风格 */
.layui-form-switch{border-color:var(--lay-color-border-2);background-color:var(--lay-color-fill-2)}
.layui-form-switch>i{background-color:var(--lay-color-gray-4)}
.layui-form-switch.layui-checkbox-disabled>i{background-color:var(--lay-color-gray-7);}
.layui-form-switch>div{color:var(--lay-color-gray-8)!important}
.layui-form-onswitch{border-color:var(--lay-color-secondary-active);background-color:var(--lay-color-secondary)}
.layui-form-onswitch>i{background-color:var(--lay-color-gray-4)}
.layui-form-onswitch>div{color:var(--lay-color-text-1)!important}
.layui-checkbox-disabled{border-color:var(--lay-color-border-2)!important}
.layui-checkbox-disabled>div{background-color:var(--lay-color-fill-3)!important;color: var(--lay-color-text-4)!important;}
.layui-checkbox-disabled>i{border-color:var(--lay-color-border-2)!important}
.layui-checkbox-disabled:hover>i{color:var(--lay-color-text-1)!important}
.layui-form-switch.layui-checkbox-disabled>div{background-color:initial!important;color: var(--lay-color-text-3)!important;}
/*复选框背景优化*/
.layui-form-checkbox>i:before{opacity:0;filter:alpha(opacity=0)}
.layui-form-checkbox:hover>i:before{opacity:1;filter:alpha(opacity=100)}
.layui-form-checked.layui-checkbox-disabled:hover>i:before,.layui-form-checked:hover>i:before,.layui-form-checked>i:before{opacity:1;filter:alpha(opacity=100)}
.layui-form-checkbox[lay-skin=primary]:hover>i:before{opacity:0;filter:alpha(opacity=0)}
.layui-form-checked[lay-skin=primary]:hover>i:before{opacity:1;filter:alpha(opacity=100)}
.layui-checkbox-disabled:hover>i:before{opacity:0;filter:alpha(opacity=0)}
/*单选框*/
.layui-form-radio>i{color:var(--lay-color-gray-8)}
.layui-form-radio:hover>*,.layui-form-radioed,.layui-form-radioed>i{color:var(--lay-color-secondary)}
.layui-radio-disabled>i{color:var(--lay-color-text-4)!important}
.layui-radio-disabled>*{color:var(--lay-color-text-4)!important}
/* 表单方框风格 */
.layui-form-pane .layui-form-label{background-color:var(--lay-color-bg-2)}
/** 分页 **/
.layui-laypage a,.layui-laypage button,.layui-laypage input,.layui-laypage select,.layui-laypage span{border:1px solid var(--lay-color-border-2)}
.layui-laypage a,.layui-laypage span{background-color: var(--lay-color-bg-2);color: var(--lay-color-text-2)}
.layui-laypage a[data-page]{color:var(--lay-color-text-2)}
.layui-laypage a:hover{color: var(--lay-color-primary)}
.layui-laypage .layui-laypage-spr{color:var(--lay-color-text-3)}
.layui-laypage .layui-laypage-curr em{color: var(--lay-color-white)}
.layui-laypage .layui-laypage-curr .layui-laypage-em{background-color: var(--lay-color-primary)}
.layui-laypage .layui-laypage-skip{color:var(--lay-color-text-3)}
.layui-laypage button,.layui-laypage input{background-color: var(--lay-color-bg-2)}
.layui-laypage input:focus,.layui-laypage select:focus{border-color: var(--lay-color-primary)!important}
/** 流加载 **/
.layui-flow-more{color:var(--lay-color-text-1)}
.layui-flow-more a cite{background-color: var(--lay-color-bg-4);color: var(--lay-color-text-1)}
.layui-flow-more a i{color:var(--lay-color-text-2)}
/** 表格 **/
.layui-table{background-color: var(--lay-color-bg-2);color: var(--lay-color-text-2)}
.layui-table-mend{background-color: var(--lay-color-bg-2)}
.layui-table-click,.layui-table-hover,.layui-table[lay-even] tbody tr:nth-child(even){background-color:var(--lay-color-fill-3)}
.layui-table-checked{background-color: var(--lay-color-fill-2);color: var(--lay-color-text-1)}
.layui-table-checked.layui-table-hover,.layui-table-checked.layui-table-click{background-color: var(--lay-color-fill-3);}
.layui-table td,.layui-table th,.layui-table-col-set,.layui-table-fixed-r,.layui-table-grid-down,.layui-table-header,.layui-table-mend,.layui-table-page,.layui-table-tips-main,.layui-table-tool,.layui-table-total,.layui-table-view,.layui-table[lay-skin=line],.layui-table[lay-skin=row]{border-color: var(--lay-color-border-2)}
.layui-table-view:after {background-color: var(--lay-color-border-2);}
.layui-table-view .layui-table td[data-edit]:hover:after{border:1px solid var(--lay-color-primary-active)}
.layui-table-loading-icon .layui-icon{color:var(--lay-color-gray-8);}
.layui-table-page{background-color: var(--lay-color-bg-2);}
.layui-table-page .layui-laypage a,
.layui-table-page .layui-laypage span{border: none;}
.layui-table-tool{background-color: var(--lay-color-bg-2);}
.layui-table-tool .layui-inline[lay-event]{color:var(--lay-color-text-3);border:1px solid var(--lay-color-border-2)}
.layui-table-tool .layui-inline[lay-event]:hover{border:1px solid var(--lay-color-border-3)}
.layui-table-tool-panel{color: var(--lay-color-text-1); border:1px solid var(--lay-color-border-2);background-color: var(--lay-color-bg-5);box-shadow:var(--lay-shadow-2)}
.layui-table-tool-panel li:hover{background-color:var(--lay-color-active)}
.layui-table-col-set{background-color: var(--lay-color-white)}
.layui-table-sort .layui-table-sort-asc{border-bottom-color:var(--lay-color-gray-8)}
.layui-table-sort .layui-table-sort-asc:hover{border-bottom-color:var(--lay-color-gray-11)}
.layui-table-sort .layui-table-sort-desc{border-top-color:var(--lay-color-gray-8)}
.layui-table-sort .layui-table-sort-desc:hover{border-top-color:var(--lay-color-gray-11)}
.layui-table-sort[lay-sort=asc] .layui-table-sort-asc{border-bottom-color:var(--lay-color-gray-13)}
.layui-table-sort[lay-sort=desc] .layui-table-sort-desc{border-top-color:var(--lay-color-gray-13)}
.layui-table-cell .layui-table-link{color: var(--lay-color-lightblue-5)}
.layui-table-body .layui-none{color:var(--lay-color-gray-8)}
.layui-table-fixed-l{box-shadow:1px 0 8px rgba(0,0,0,1)}
.layui-table-fixed-r{box-shadow:-1px 0 8px rgba(0,0,0,1)}
.layui-table-edit{box-shadow:var(--lay-shadow-1);background-color: var(--lay-color-bg-2)}
.layui-table-edit:focus{border-color:var(--lay-color-secondary)!important}
select.layui-table-edit{border-color:var(--lay-color-border-2)}
.layui-table-grid-down{background-color: var(--lay-color-bg-5);color:var(--lay-color-gray-8)}
.layui-table-grid-down:hover{background-color:var(--lay-color-bg-5)}
/* 单元格多行展开风格 */
.layui-table-cell-c{background-color: var(--lay-color-gray-13);color: var(--lay-color-text-1); border-color: var(--lay-color-border-3);}
.layui-table-cell-c:hover{border-color: var(--lay-color-secondary-hover);}
/* 单元格 TIPS 展开风格 */
body .layui-table-tips .layui-layer-content{box-shadow:var(--lay-shadow-3)}
.layui-table-tips-main{background-color: var(--lay-color-bg-5);color: var(--lay-color-text-3)}
.layui-table-tips-c{background-color:var(--lay-color-gray-13);color: var(--lay-color-text-1)}
.layui-table-tips-c:hover{background-color:var(--lay-color-gray-10)}
/** 文件上传 **/
.layui-upload-choose{color:var(--lay-color-gray-8)}
.layui-upload-drag{border:1px dashed var( --lay-color-border-2);background-color: var(--lay-color-bg-4);color: var(--lay-color-text-2)}
.layui-upload-drag .layui-icon{color: var(--lay-color-primary)}
.layui-upload-drag[lay-over]{border-color: var(--lay-color-primary)}
/* 基础菜单元素 */
.layui-menu{background-color: var(--lay-color-bg-2)}
.layui-menu li{color: var(--lay-color-text-1)}
.layui-menu li:hover{background-color: var(--lay-color-bg-5)}
.layui-menu li.layui-disabled,.layui-menu li.layui-disabled *{color:var(--lay-color-text-4)!important}
.layui-menu .layui-menu-item-group>.layui-menu-body-title{color: var(--lay-color-text-3)}
.layui-menu .layui-menu-item-none{color: var(--lay-color-text-3);}
.layui-menu .layui-menu-item-divider{border-bottom:1px solid var(--lay-color-border-2)}
.layui-menu .layui-menu-item-group:hover,
.layui-menu .layui-menu-item-none:hover,
.layui-menu .layui-menu-item-divider:hover{background: none;}
.layui-menu .layui-menu-item-up>.layui-menu-body-title{color: var(--lay-color-text-1)}
.layui-menu .layui-menu-item-down:hover>.layui-menu-body-title>.layui-icon,.layui-menu .layui-menu-item-up>.layui-menu-body-title:hover>.layui-icon{color: var(--lay-color-text-1)}
.layui-menu .layui-menu-item-checked,.layui-menu .layui-menu-item-checked2{background-color:var(--lay-color-active)!important;color:var(--lay-color-secondary)}
.layui-menu .layui-menu-item-checked a,.layui-menu .layui-menu-item-checked2 a{color:var(--lay-color-secondary)}
.layui-menu .layui-menu-item-checked:after{border-right:3px solid var(--lay-color-secondary)}
.layui-menu-body-title a{color: var(--lay-color-text-1)}
.layui-menu-lg .layui-menu-body-title a:hover,.layui-menu-lg li:hover{color:var(--lay-color-secondary)}
/* 下拉菜单 */
.layui-dropdown{background-color: var(--lay-color-bg-5)}
.layui-dropdown.layui-panel,.layui-dropdown .layui-panel{background-color: var(--lay-color-bg-5);box-shadow: var(--lay-shadow-2)}
.layui-dropdown.layui-panel .layui-menu{background-color: var(--lay-color-bg-5)}
/** 导航菜单 **/
.layui-nav{background-color:var(--lay-color-black-6);color: var(--lay-color-white)}
.layui-nav .layui-nav-item a{color: var(--lay-color-text-1);}
.layui-nav .layui-this:after,.layui-nav-bar{background-color:var(--lay-color-secondary)}
.layui-nav .layui-nav-item a:hover,.layui-nav .layui-this a{color: var(--lay-color-text-1)}
.layui-nav-child{box-shadow:var(--lay-shadow-2);border:1px solid var(--lay-color-border-2);background-color: var(--lay-color-bg-5)}
.layui-nav .layui-nav-child a{color: var(--lay-color-text-1)}
.layui-nav .layui-nav-child a:hover{background-color: var(--lay-color-bg-5);color: var(--lay-color-text-1)}
.layui-nav-child dd.layui-this{background-color: var(--lay-color-bg-5);color: var(--lay-color-text-1)}
.layui-nav-tree .layui-nav-child dd.layui-this,.layui-nav-tree .layui-nav-child dd.layui-this a,.layui-nav-tree .layui-this,.layui-nav-tree .layui-this>a,.layui-nav-tree .layui-this>a:hover{background-color: var(--lay-color-primary);color: var(--lay-color-white)}
.layui-nav-itemed>a,.layui-nav-tree .layui-nav-title a,.layui-nav-tree .layui-nav-title a:hover{color: var(--lay-color-white)!important}
.layui-nav-tree .layui-nav-bar{background-color:var(--lay-color-primary)}
.layui-nav-tree .layui-nav-child{background: none; background-color:rgba(0, 0, 0, .3); border: none; box-shadow: none;}
.layui-nav-tree .layui-nav-child a{color: var(--lay-color-white);color: var(--lay-color-text-1)}
.layui-nav-tree .layui-nav-child a:hover{background: none; color: var(--lay-color-white)}
.layui-nav.layui-bg-gray,.layui-nav-tree.layui-bg-gray{background-color: var(--lay-color-bg-2) !important;color: var(--lay-color-text-1);}
.layui-nav-tree.layui-bg-gray .layui-nav-child{background-color: rgba(0, 0, 0, .3) !important;}
.layui-nav-tree.layui-bg-gray a,.layui-nav.layui-bg-gray .layui-nav-item a{color: var(--lay-color-text-1)}
.layui-nav.layui-bg-gray .layui-nav-child{background-color: var(--lay-color-bg-5);}
.layui-nav-tree.layui-bg-gray .layui-nav-itemed>a{color: var(--lay-color-text-1)!important}
.layui-nav.layui-bg-gray .layui-this a{color:var(--lay-color-secondary)}
.layui-nav-tree.layui-bg-gray .layui-nav-child dd.layui-this,.layui-nav-tree.layui-bg-gray .layui-nav-child dd.layui-this a,.layui-nav-tree.layui-bg-gray .layui-this,.layui-nav-tree.layui-bg-gray .layui-this>a{color:var(--lay-color-secondary)!important}
.layui-nav-tree.layui-bg-gray .layui-nav-bar{background-color:var(--lay-color-secondary)}
/** 面包屑 **/
.layui-breadcrumb a{color:var(--lay-color-gray-7)!important}
.layui-breadcrumb a:hover{color:var(--lay-color-secondary)!important}
.layui-breadcrumb a cite{color:var(--lay-color-gray-8)}
.layui-breadcrumb span[lay-separator]{color:var(--lay-color-gray-7)}
/** Tab 选项卡 **/
.layui-tab .layui-tab-title:after{border-bottom-color: var(--lay-color-border-1);}
.layui-tab-title .layui-this{color: var(--lay-color-text-2)}
.layui-tab-title .layui-this:after{border-bottom-color: var(--lay-color-bg-1)}
.layui-tab-bar{background-color: var(--lay-color-bg-3)}
.layui-tab-more li.layui-this:after{border-bottom-color:var(--lay-color-border-1)}
.layui-tab-title li .layui-tab-close{color:var(--lay-color-gray-8)}
.layui-tab-title li .layui-tab-close:hover{background-color:var(--lay-color-danger);color: var(--lay-color-white)}
.layui-tab-brief>.layui-tab-title .layui-this{color:var( --lay-color-primary)}
.layui-tab-brief>.layui-tab-more li.layui-this:after,.layui-tab-brief>.layui-tab-title .layui-this:after{border-bottom:2px solid var(--lay-color-secondary)}
.layui-tab-card{box-shadow: var(--lay-shadow-1)}
.layui-tab-card>.layui-tab-title{background-color: var(--lay-color-bg-2)}
.layui-tab-card>.layui-tab-title .layui-this{background-color: var(--lay-color-bg-1)}
.layui-tab-card>.layui-tab-title .layui-this:after{border-bottom-color: var(--lay-color-bg-1)}
.layui-tab-card>.layui-tab-more .layui-this{color:var(--lay-color-secondary)}
/** tabs 标签页 **/
.layui-tabs-header:after,
.layui-tabs-scroll:after{border-bottom-color: var(--lay-color-border-1);}
.layui-tabs-card>.layui-tabs-header .layui-this{background-color: transparent;}
.layui-tabs-card>.layui-tabs-header .layui-this:after{border-color: var(--lay-color-border-1); border-bottom-color: var(--lay-color-bg-1);}
.layui-tabs-card.layui-panel>.layui-tabs-header .layui-this:after{border-bottom-color: var(--lay-color-bg-2);}
.layui-tabs-bar .layui-icon{background-color: var(--lay-color-bg-1); color: var(--lay-color-text-2); border-color: var(--lay-color-border-1); box-shadow: 2px 0 5px 0 rgb(0 0 0 / 32%);}
.layui-tabs-bar .layui-icon-next{box-shadow: -2px 0 5px 0 rgb(0 0 0 / 32%);}
/*时间线*/
.layui-timeline-axis{background-color: var(--lay-color-bg-4);color:var(--lay-color-secondary)}
.layui-timeline-axis:hover{color:var(--lay-color-red-6)}
.layui-timeline-item:before{background-color: var(--lay-color-bg-3)}
/*徽章*/
.layui-badge,.layui-badge-dot,.layui-badge-rim{background-color:var(--lay-color-red-6);color: var(--lay-color-white)}
.layui-badge-rim{background-color: var(--lay-color-white);color:var(--lay-color-black-6)}
/* carousel 轮播 */
.layui-carousel{background-color:var(--lay-color-gray-2)}
.layui-carousel>[carousel-item]:before{color:var(--lay-color-gray-8);-moz-osx-font-smoothing:grayscale}
.layui-carousel>[carousel-item]>*{background-color:var(--lay-color-gray-2)}
.layui-carousel-arrow{background-color:rgba(0,0,0,.2);color: var(--lay-color-white)}
.layui-carousel-arrow:hover,.layui-carousel-ind ul:hover{background-color:var(--lay-color-black)}
.layui-carousel[lay-indicator=outside] .layui-carousel-ind ul{background-color:var(--lay-color-black)}
.layui-carousel-ind ul{background-color:rgba(0,0,0,.2)}
.layui-carousel-ind ul li{background-color:var(--lay-color-gray-3);background-color: var(--lay-color-text-3)}
.layui-carousel-ind ul li:hover{background-color: var(--lay-color-white)}
.layui-carousel-ind ul li.layui-this{background-color: var(--lay-color-white)}
/** fixbar **/
.layui-fixbar li{background-color:var(--lay-color-black-5);color: var(--lay-color-text-1)}
/** 表情面板 **/
body .layui-util-face .layui-layer-content{background-color: var(--lay-color-bg-5);color:var(--lay-color-text-2)}
.layui-util-face ul{border:1px solid var(--lay-color-border-3);background-color: var(--lay-color-bg-5);box-shadow:var(--lay-shadow-2)}
.layui-util-face ul li{border:1px solid var(--lay-color-border-2)}
.layui-util-face ul li:hover{border:1px solid var(--lay-color-red-7);background: var(--lay-color-text-1)}
/** 代码文本修饰 **/
.layui-code{border:1px solid var(--lay-color-border-2);background-color: var(--lay-color-bg-white);color: var(--lay-color-text-2)}
/** 穿梭框 **/
.layui-transfer-box,.layui-transfer-header,.layui-transfer-search{border-color: var(--lay-color-border-2)}
.layui-transfer-box{background-color: var(--lay-color-bg-2)}
.layui-transfer-search .layui-icon-search{color:var(--lay-color-gray-8)}
.layui-transfer-active .layui-btn{background-color:var( --lay-color-secondary);border-color:var( --lay-color-secondary);color: var(--lay-color-white)}
.layui-transfer-active .layui-btn-disabled{background-color:var(--lay-color-gray-2);border-color:var(--lay-color-gray-3);color:var(--lay-color-gray-8)}
.layui-transfer-data li:hover{background-color:var(--lay-color-active)}
/* chrome 105 */
.layui-transfer-data li:hover:has([lay-filter="layTransferCheckbox"][disabled]){background-color:var(--lay-color-bg-2)}
.layui-transfer-data .layui-none{color:var(--lay-color-gray-7)}
/** 评分组件 **/
.layui-rate li i.layui-icon{color:var(--lay-color-orange-6)}
/** 颜色选择器 **/
.layui-colorpicker{border:1px solid var(--lay-color-border-1)}
.layui-colorpicker:hover{border-color: var(--lay-color-border-2)}
.layui-colorpicker-trigger-span{border:1px solid var(--lay-color-border-1)}
.layui-colorpicker-trigger-i{color: var(--lay-color-white)}
.layui-colorpicker-trigger-i.layui-icon-close{color:var(--lay-color-black-7)}
.layui-colorpicker-main{background: var(--lay-color-bg-2);border:1px solid var( --lay-color-border-2);box-shadow:var(--lay-shadow-2)}
.layui-colorpicker-basis-white{background:linear-gradient(90deg, #fff,hsla(0,0%,100%,0))} /* danger: 勿改*/
.layui-colorpicker-basis-black{background:linear-gradient(0deg,#000,transparent)} /* danger: 勿改*/
.layui-colorpicker-basis-cursor{border:1px solid var(--lay-color-white)}
.layui-colorpicker-side{background:linear-gradient(linear-gradient(#F00, #FF0, #0F0, #0FF, #00F, #F0F, #F00))} /* danger: 勿改*/
.layui-colorpicker-side-slider{box-shadow:var(--lay-shadow-1);background: var(--lay-color-white);border:1px solid var(--lay-color-gray-2)}
.layui-colorpicker-alpha-slider{box-shadow:var(--lay-shadow-1);background: var(--lay-color-white);border:1px solid var(--lay-color-gray-2)}
.layui-colorpicker-pre.layui-this{box-shadow:var(--lay-shadow-1)}
.layui-colorpicker-pre.selected{box-shadow:var(--lay-shadow-1)}
.layui-colorpicker-main-input input.layui-input{color: var(--lay-color-text-2)}
/** 滑块 **/
.layui-slider{background: var( --lay-color-bg-5)}
.layui-slider-step{background: var(--lay-color-fill-4)}
.layui-slider-wrap-btn{background: var(--lay-color-bg-4)}
.layui-slider-tips{color: var(--lay-color-text-1);background:var(--lay-color-black);box-shadow: var(--lay-shadow-3)}
.layui-slider-tips:after{border-color:var(--lay-color-black) transparent transparent transparent}
.layui-slider-input{border:1px solid var(--lay-color-border-1)}
.layui-slider-input-btn{border-left:1px solid var(--lay-color-border-1)}
.layui-slider-input-btn i{color:var(--lay-color-gray-9)}
.layui-slider-input-btn i:first-child{border-bottom:1px solid var(--lay-color-border-1)}
.layui-slider-input-btn i:hover{color:var(--lay-color-primary)}
/** 树组件 **/
.layui-tree-line .layui-tree-set .layui-tree-set:after{border-top:1px dotted var(--lay-color-gray-7)}
.layui-tree-entry:hover{background-color: var(--lay-color-bg-4)}
.layui-tree-line .layui-tree-entry:hover{background-color:var(--lay-color-black)}
.layui-tree-line .layui-tree-entry:hover .layui-tree-txt{color:var(--lay-color-text-3)}
.layui-tree-entry:hover:has(span.layui-tree-txt.layui-disabled){background-color: transparent !important}
.layui-tree-line .layui-tree-set:before{border-left:1px dotted var(--lay-color-gray-7)}
.layui-tree-iconClick{color:var(--lay-color-gray-7)}
.layui-tree-icon{border:1px solid var(--lay-color-gray-8)}
.layui-tree-icon .layui-icon{color:var(--lay-color-text-1)}
.layui-tree-iconArrow:after{border-color:transparent transparent transparent var(--lay-color-gray-7)}
.layui-tree-txt{color:var(--lay-color-text-2)}
.layui-tree-search{color:var(--lay-color-black-7)}
.layui-tree-btnGroup .layui-icon:hover{color:var(--lay-color-text-2)}
.layui-tree-editInput{background-color:var(--lay-color-fill-2)}
.layui-tree-emptyText{color:var(--lay-color-text-2)}
/*code 不处理*/
.layui-code-view{border:1px solid var(--lay-color-border-1);}
.layui-code-view:not(.layui-code-hl){background-color: var(--lay-color-bg-2);color: var(--lay-color-text-2);}
.layui-code-header{border-bottom: 1px solid var(--lay-color-border-1); background-color: var(--lay-color-bg-2)}
.layui-code-header > .layui-code-header-about{color: var(--lay-color-text-2);}
.layui-code-view:not(.layui-code-hl) .layui-code-ln-side{border-color: var(--lay-color-border-1); background-color: var(--lay-color-bg-2);}
.layui-code-nowrap > .layui-code-ln-side{background: none !important;}
.layui-code-fixbar > span{color: var(--lay-color-text-3);}
.layui-code-fixbar > span:hover{color: var(--lay-color-secondary-hover);}
.layui-code-theme-dark,
.layui-code-theme-dark > .layui-code-header{border-color: rgb(126 122 122 / 15%); background-color: #1f1f1f;}
.layui-code-theme-dark{border-width: 1px; color: #ccc;}
.layui-code-theme-dark > .layui-code-ln-side{border-right-color: #2a2a2a; background: none; color: #6e7681;}
.layui-code-view.layui-code-hl > .layui-code-ln-side{background-color: transparent;}
.layui-code-theme-dark.layui-code-hl,
.layui-code-theme-dark.layui-code-hl > .layui-code-ln-side{border-color: rgb(126 122 122 / 15%);}
.layui-code-full{background-color: var(--lay-color-bg-1)}
/*日期选择器*/
.layui-laydate-header i{color:var(--lay-color-gray-8)}
.laydate-day-holidays:before{color:var(--lay-color-red-6)}
.layui-laydate .layui-this .laydate-day-holidays:before{color: var(--lay-color-white)}
.layui-laydate-footer span{border:1px solid var(--lay-color-border-2);background-color: var(--lay-color-bg-5)}
.layui-laydate-footer span:hover{color:var(--lay-color-secondary)}
.layui-laydate-footer span.layui-laydate-preview{border-color:transparent!important;}
.layui-laydate-footer span.layui-laydate-preview:hover{color:var(--lay-color-text-1) !important}
.layui-laydate-shortcut+.layui-laydate-main{border-left:1px solid var(--lay-color-border-2)}
.layui-laydate .layui-laydate-list{background-color: var(--lay-color-bg-5)}
.layui-laydate-hint{color:var(--lay-color-danger)}
.layui-laydate-range .laydate-main-list-1 .layui-laydate-content,.layui-laydate-range .laydate-main-list-1 .layui-laydate-header{border-left:1px solid var(--lay-color-border-2)}
.layui-laydate,.layui-laydate-hint{border-color: var(--lay-color-border-2);box-shadow:var(--lay-shadow-3);background-color: var(--lay-color-bg-5);color: var(--lay-color-text-1)}
.layui-laydate{box-shadow: var(--lay-shadow-2)}
.layui-laydate-hint{border-color:var(--lay-color-border-1)}
.layui-laydate-header{border-bottom:1px solid var( --lay-color-border-2)}
.layui-laydate-header i:hover,.layui-laydate-header span:hover{color:var(--lay-color-secondary)}
.layui-laydate-content th{color: var(--lay-color-text-1)}
.layui-laydate-content td{color: var(--lay-color-text-1)}
.layui-laydate-content td.laydate-day-now{color:var(--lay-color-secondary)}
.layui-laydate-content td.laydate-day-now:after{border:1px solid var(--lay-color-secondary)}
.layui-laydate-linkage .layui-laydate-content td.laydate-selected>div{background-color:var(--lay-color-green-8);}
.layui-laydate-linkage .laydate-selected:hover>div{background-color:var(--lay-color-green-8)!important}
.layui-laydate-content td>div:hover,.layui-laydate-list li:hover,.layui-laydate-shortcut>li:hover{background-color: var(--lay-color-fill-2);color: var(--lay-color-text-2)}
.layui-laydate-content td.laydate-disabled>div:hover{background-color: var(--lay-color-bg-5);color: var(--lay-color-text-4)}
.laydate-time-list li ol{border:1px solid var(--lay-color-border-2)}
.laydate-time-list>li:hover{background: 0 0;}
.layui-laydate-content .laydate-day-next,.layui-laydate-content .laydate-day-prev{color: var(--lay-color-text-3)}
.layui-laydate-linkage .laydate-selected.laydate-day-next>div,.layui-laydate-linkage .laydate-selected.laydate-day-prev>div{background: none!important}
.layui-laydate-footer{border-top:1px solid var(--lay-color-border-2)}
.layui-laydate-hint{color:var(--lay-color-danger)}
.laydate-day-mark::after{background-color:var(--lay-color-secondary)}
.layui-laydate-footer span[lay-type=date]{color:var(--lay-color-secondary)}
.layui-laydate .layui-this,.layui-laydate .layui-this>div{background-color:var(--lay-color-secondary)!important;color: var(--lay-color-white)!important}
.layui-laydate .laydate-disabled,.layui-laydate .laydate-disabled:hover{color: var(--lay-color-text-4)!important}
.layui-laydate .layui-this.laydate-disabled,.layui-laydate .layui-this.laydate-disabled>div{background-color: var(--lay-color-fill-1) !important;color: var(--lay-color-text-4) !important;}
.laydate-theme-molv .layui-laydate-header{background-color:var(--lay-color-primary)}
.laydate-theme-molv .layui-laydate-header i,.laydate-theme-molv .layui-laydate-header span{color:var(--lay-color-gray-2)}
.laydate-theme-molv .layui-laydate-header i:hover,.laydate-theme-molv .layui-laydate-header span:hover{color: var(--lay-color-white)}
.laydate-theme-molv .layui-laydate-content{border:1px solid var(--lay-color-border-2)}
.laydate-theme-molv .layui-this, .laydate-theme-molv .layui-this>div{background-color: var(--lay-color-primary) !important;}
.laydate-theme-molv .layui-laydate-footer{border:1px solid var(--lay-color-border-2)}
.laydate-theme-grid .laydate-month-list>li,.laydate-theme-grid .laydate-year-list>li,.laydate-theme-grid .layui-laydate-content td,.laydate-theme-grid .layui-laydate-content thead{border:1px solid var(--lay-color-border-2)}
.layui-laydate-linkage.laydate-theme-grid .laydate-selected,.layui-laydate-linkage.laydate-theme-grid .laydate-selected:hover{background-color:var(--lay-color-gray-3)!important;color:var(--lay-color-primary)!important}
.layui-laydate-linkage.laydate-theme-grid .laydate-selected.laydate-day-next,.layui-laydate-linkage.laydate-theme-grid .laydate-selected.laydate-day-prev{color:var(--lay-color-gray-6)!important}
.layui-laydate.laydate-theme-circle .layui-laydate-content table td.layui-this{background-color:transparent!important}
/*layer*/
.layui-layer{background-color: var(--lay-color-bg-3);box-shadow:var(--lay-shadow-3)}
.layui-layer-border{border:1px solid var(--lay-color-border-2);box-shadow:var(--lay-shadow-3)}
.layui-layer-move{background-color: var(--lay-color-bg-5)}
.layui-layer-title{border-bottom:1px solid var(--lay-color-border-2);color: var(--lay-color-text-1)}
.layui-layer-setwin span{color: var(--lay-color-text-1)}
.layui-layer-setwin .layui-layer-min:before{border-bottom-color:var(--lay-color-text-1)}
.layui-layer-setwin .layui-layer-min:hover:before{border-bottom-color:var(--lay-color-info-hover)}
.layui-layer-setwin .layui-layer-max:after,.layui-layer-setwin .layui-layer-max:before{border:1px solid var(--lay-color-text-3)}
.layui-layer-setwin .layui-layer-max:hover:after,.layui-layer-setwin .layui-layer-max:hover:before{border-color:var(--lay-color-info-hover)}
.layui-layer-setwin .layui-layer-maxmin:after,.layui-layer-setwin .layui-layer-maxmin:before{background-color: var(--lay-color-bg-5)}
.layui-layer-setwin .layui-layer-close2{color:var(--lay-color-text-1);background-color:var(--lay-color-gray-10)}
.layui-layer-setwin .layui-layer-close2:hover{background-color:var(--lay-color-normal)}
.layui-layer-btn a{border:1px solid var(--lay-color-border-2);background-color: var( --lay-color-bg-3);color: var(--lay-color-text-2)}
.layui-layer-btn .layui-layer-btn0{border-color: transparent;background-color: var(--lay-color-normal);color: var(--lay-color-text-1)}
.layui-layer-dialog .layui-layer-content .layui-layer-face{color:var(--lay-color-gray-9)}
.layui-layer-dialog .layui-layer-content .layui-icon-tips{color:var(--lay-color-warning)}
.layui-layer-dialog .layui-layer-content .layui-icon-success{color: var(--lay-color-success)}
.layui-layer-dialog .layui-layer-content .layui-icon-error{top: 19px; color: var(--lay-color-danger)}
.layui-layer-dialog .layui-layer-content .layui-icon-question{color: var(--lay-color-warning);}
.layui-layer-dialog .layui-layer-content .layui-icon-lock{color: var(--lay-color-gray-10)}
.layui-layer-dialog .layui-layer-content .layui-icon-face-cry{color:var(--lay-color-danger)}
.layui-layer-dialog .layui-layer-content .layui-icon-face-smile{color:var(--lay-color-success)}
.layui-layer-rim{border:6px solid var(--lay-color-gray-8);border:6px solid var(--lay-color-border-2)}
.layui-layer-msg{border:1px solid var( --lay-color-border-1)}
.layui-layer-hui{background-color: var(--lay-color-bg-3);color: var(--lay-color-text-1)}
.layui-layer-hui .layui-layer-close{color: var(--lay-color-white)}
.layui-layer-loading-icon{color:var(--lay-color-gray-9)}
.layui-layer-loading-2:after,.layui-layer-loading-2:before{border:3px solid var(--lay-color-gray-6)}
.layui-layer-loading-2:after{border-color:transparent;border-left-color: var(--lay-color-normal)}
.layui-layer-tips .layui-layer-content{box-shadow: var(--lay-shadow-3);background-color: var(--lay-color-bg-5);color: var(--lay-color-text-1)}
.layui-layer-tips i.layui-layer-TipsG{border-color:transparent}
.layui-layer-tips i.layui-layer-TipsB,.layui-layer-tips i.layui-layer-TipsT{border-right-color:var(--lay-color-black)}
.layui-layer-tips i.layui-layer-TipsL,.layui-layer-tips i.layui-layer-TipsR{border-bottom-color:var(--lay-color-black)}
.layui-layer-lan .layui-layer-title{background:var(--lay-color-blue-5);color: var(--lay-color-text-1)}
.layui-layer-lan .layui-layer-btn{border-top:1px solid var(--lay-color-border-3)}
.layui-layer-lan .layui-layer-btn a{background: var(--lay-color-white);border-color:var(--lay-color-border-3);color: var(--lay-color-black-7)}
.layui-layer-lan .layui-layer-btn .layui-layer-btn1{background: var(--lay-color-gray-7)}
.layui-layer-molv .layui-layer-title{background:var(--lay-color-layuigreen-6);color: var(--lay-color-text-1)}
.layui-layer-molv .layui-layer-btn a{background:var(--lay-color-layuigreen-6);border-color:var(--lay-color-layuigreen-6)}
.layui-layer-molv .layui-layer-btn .layui-layer-btn1{background:var(--lay-color-gray-7)}
.layui-layer-win10{border-color: var(--lay-color-border-2)}
.layui-layer-win10 .layui-layer-btn{background-color: var(--lay-color-bg-2);border-color: var(--lay-color-border-2)}
.layui-layer-win10.layui-layer-dialog .layui-layer-content{color: var(--lay-color-blue-7)}
.layui-layer-win10 .layui-layer-btn .layui-layer-btn0{border-color: var(--lay-color-blue-9);background-color: var(--lay-color-bg-5);color: var(--lay-color-text-1)}
.layui-layer-win10 .layui-layer-btn .layui-layer-btn1{border-color: var(--lay-color-border-2);background-color: var(--lay-color-bg-5);color: var(--lay-color-text-1)}
.layui-layer-win10 .layui-layer-btn a:hover{background-color: var(--lay-color-blue-10);border-color: var(--lay-color-blue-8)}
.layui-layer-prompt .layui-layer-input{border:1px solid var(--lay-color-border-2);color: var(--lay-color-text-2)}
.layui-layer-tab{box-shadow:var(--lay-shadow-3)}
.layui-layer-tab .layui-layer-title span.layui-this{border-left:1px solid var(--lay-color-border-2);border-right:1px solid var(--lay-color-border-2);background-color: var(--lay-color-bg-3)}
.layui-layer-photos{background: none; box-shadow: none;}
.layui-layer-photos-prev,.layui-layer-photos-next{color:var(--lay-color-gray-9)}
.layui-layer-photos-prev:hover,.layui-layer-photos-next:hover{color:var(--lay-color-text-1)}
.layui-layer-photos-toolbar{background-color:#333;background-color: var(--lay-color-bg-5);color: var(--lay-color-text-1)}
.layui-layer-photos-toolbar *{color: var(--lay-color-text-1)}
.layui-layer-photos-toolbar a:hover{color: var(--lay-color-text-2)}
.layui-layer-photos-header > span:hover{background-color: var(--lay-color-fill-2)}
.layui-layer-tips i.layui-layer-TipsB,.layui-layer-tips i.layui-layer-TipsT{border-right-color: var(--lay-color-bg-5)}
.layui-layer-tips i.layui-layer-TipsL,.layui-layer-tips i.layui-layer-TipsR{border-bottom-color: var(--lay-color-bg-5)}
.layui-layer-prompt .layui-layer-input{border:1px solid var(--lay-color-border-2);color:var(--lay-color-text-1);background-color:var(--lay-color-black)}
.layui-layer-prompt .layui-layer-input:focus{outline:0}
/*fix style*/
.layui-layer-loading{background:0 0;box-shadow:0 0}
.layui-btn-primary{border-color:transparent}
.layui-btn-group .layui-btn:first-child{border-left:none}
.layui-btn-group .layui-btn-primary:hover{border-top-color:transparent; border-bottom-color: transparent;}
.layui-menu li:hover{background-color:var(--lay-color-fill-2)}
.layui-nav-child dd.layui-this{background-color:var(--lay-color-fill-2)}
.layui-nav .layui-nav-child a:hover{background-color:var(--lay-color-fill-2)}
.layui-nav .layui-nav-item a:hover,.layui-nav .layui-this a{background-color: var(--lay-color-fill-2)}
.layui-nav-child dd.layui-this{background-color: var(--lay-color-fill-2)}
.layui-tab-card>.layui-tab-title .layui-this:after{border-bottom-color:var(--lay-color-bg-1)}
.layui-form-select dl dd:hover{background-color:var(--lay-color-fill-2)}
.layui-form-select dl dd.layui-this{background-color:var(--lay-color-fill-2)}
.layui-laypage button{color:var(--lay-color-text-1)}
.layui-table[lay-even] tbody tr:nth-child(even){background-color:var(--lay-color-fill-4)}
.layui-menu .layui-menu-item-checked,.layui-menu .layui-menu-item-checked2{background-color:var(--lay-color-fill-2)!important}
.layui-input-split{background-color: var(--lay-color-bg-2);}
.layui-input-wrap .layui-input-prefix.layui-input-split{border-width: 1px;}
.layui-input-wrap .layui-input-split:has(+.layui-input:hover) {border-color: var(--lay-color-border-2);}
.layui-input-wrap .layui-input-split:has(+.layui-input:focus) {border-color: var(--lay-color-secondary-hover);}
.layui-layer-tab .layui-layer-title span:first-child{border-left: none !important;}
.layui-slider-input.layui-input,
.layui-slider-input .layui-input {background-color: var(--lay-color-bg-2);}
/*# sourceMappingURL=layui-theme-dark.css.map */

File diff suppressed because one or more lines are too long

534
web/static/src/override.css Normal file
View File

@@ -0,0 +1,534 @@
blockquote,body,button,dd,div,dl,dt,form,h1,h2,h3,h4,h5,h6,input,li,ol,p,pre,td,textarea,th,ul{-webkit-tap-highlight-color: rgba(0, 0, 0, 0)} /*danger: 勿改*/
body{color:var(--lay-color-text-2);background-color: var(--lay-color-bg-1); color-scheme: dark;}
hr{border-bottom:1px solid var(--lay-color-border-2)!important}
a{color:var(--lay-color-text-1);}
a:hover{color:var(--lay-color-text-3)}
/* 三角形 */
.layui-edge{border-color:transparent}
.layui-edge-top{border-bottom-color:var(--lay-color-border-4)}
.layui-edge-right{border-left-color:var(--lay-color-border-4)}
.layui-edge-bottom{border-top-color:var(--lay-color-border-4)}
.layui-edge-left{border-right-color:var(--lay-color-border-4)}
/* 禁用文字 */
.layui-disabled,.layui-disabled:hover{color:var(--lay-color-text-4)!important}
/* 图标 */
.layui-icon{-moz-osx-font-smoothing:grayscale}
/* admin 布局 */
.layui-layout-admin .layui-header{background-color:var(--lay-color-bg-2)}
.layui-layout-admin .layui-footer{box-shadow:-1px 0 4px rgb(0 0 0 / 12%);background-color:var(--lay-color-bg-2)}
.layui-layout-admin .layui-logo{color:var(--lay-color-primary);box-shadow:0 1px 2px 0 rgb(0 0 0 / 15%)}
/* 引用 */
.layui-elem-quote{border-left:5px solid var(--lay-color-secondary);background-color:var(--lay-color-fill-1)}
.layui-quote-nm{border-color: var(--lay-color-fill-1)}
/* 进度条 */
.layui-progress{background-color: var(--lay-color-bg-3)}
.layui-progress-bar{background-color:var( --lay-color-secondary)}
.layui-progress-text{color:var(--lay-color-text-2)}
.layui-progress-big .layui-progress-text{color: var(--lay-color-text-1)}
/* 折叠面板 */
.layui-colla-title{color: var(--lay-color-text-1);background-color: var(--lay-color-bg-2)}
.layui-colla-content{color:var(--lay-color-text-2)}
/* 卡片面板 */
.layui-card{background-color: var(--lay-color-bg-2);box-shadow:var(--lay-shadow-1)}
.layui-card-header{border-bottom:1px solid var(--lay-color-border-2);color:var(--lay-color-text-1)}
/* 常规面板 */
.layui-panel{box-shadow:var(--lay-shadow-1);background-color: var( --lay-color-bg-2);color: var(--lay-color-text-1)}
.layui-menu-body-panel{box-shadow: var(--lay-shadow-2)}
/* 窗口面板 */
.layui-panel-window{border-top:5px solid var(--lay-color-border-2);background-color: var(--lay-color-bg-2)}
/* 背景颜色 */
.layui-bg-red{background-color:var(--lay-color-red-6)!important;color: var(--lay-color-white)!important}
.layui-bg-orange{background-color:var(--lay-color-orange-6)!important;color: var(--lay-color-white)!important}
.layui-bg-green{background-color:var(--lay-color-layuigreen-6)!important;color: var(--lay-color-white)!important}
.layui-bg-cyan{background-color:var(--lay-color-cyan-6)!important;color: var(--lay-color-white)!important}
.layui-bg-blue{background-color: var(--lay-color-blue-6)!important;color: var(--lay-color-white)!important}
.layui-bg-black{background-color:var(--lay-color-black-6)!important;color: var(--lay-color-white)!important}
.layui-bg-purple{background-color: var(--lay-color-purple-6)!important; color: var(--lay-color-white)!important;}
.layui-bg-gray{background-color:var(--lay-color-gray-1)!important;color: var(--lay-color-black-6)!important}
/* 徽章 */
.layui-badge-rim,.layui-border,.layui-colla-content,.layui-colla-item,.layui-collapse,.layui-elem-field,.layui-form-pane .layui-form-item[pane],.layui-form-pane .layui-form-label,.layui-input,.layui-input-split,.layui-panel,.layui-select,.layui-tab-bar,.layui-tab-card,.layui-tab-title,.layui-tab-title .layui-this:after,.layui-textarea{border-color: var(--lay-color-border-1)}
/* 边框颜色 */
.layui-border{color:var(--lay-color-text-1)!important}
.layui-border-red{border-color:var(--lay-color-red-6)!important;color:var(--lay-color-red-6)!important}
.layui-border-orange{border-color:var(--lay-color-orange-6)!important;color:var(--lay-color-orange-6)!important}
.layui-border-green{border-color:var(--lay-color-layuigreen-6)!important;color:var(--lay-color-layuigreen-6)!important}
.layui-border-cyan{border-color:var(--lay-color-cyan-6)!important;color:var(--lay-color-cyan-6)!important}
.layui-border-blue{border-color: var(--lay-color-blue-6)!important;color: var(--lay-color-blue-6)!important}
.layui-border-purple{border-color: var(--lay-color-purple-6)!important; color: var(--lay-color-purple-6)!important;}
.layui-border-black{border-color:var(--lay-color-black-6)!important;color:var(--lay-color-text-1)!important}
/* 文本区域 */
.layui-text{color:var(--lay-color-text-2)}
.layui-text-em,.layui-word-aux{color: var(--lay-color-text-3)!important}
.layui-text a:not(.layui-btn){color:var(--lay-color-lightblue-6)}
.layui-text blockquote:not(.layui-elem-quote){border-left:5px solid var(--lay-color-border-4)}
/* 字体颜色 */
.layui-font-red{color:var(--lay-color-red-6)!important}
.layui-font-orange{color:var(--lay-color-orange-6)!important}
.layui-font-green{color:var(--lay-color-layuigreen-6)!important}
.layui-font-cyan{color:var(--lay-color-cyan-6)!important}
.layui-font-blue{color:var(--lay-color-lightblue-6)!important}
.layui-font-black{color:var(--lay-color-black)!important}
.layui-font-purple{color:var(--lay-color-purple-6)!important;}
.layui-font-gray{color:var(--lay-color-gray-7)!important}
/* 按钮 */
.layui-btn{border:1px solid transparent;background-color:var(--lay-color-primary);color: var(--lay-color-text-1)}
.layui-btn:hover{color: var(--lay-color-text-2)}
.layui-btn-primary{border-color:var(--lay-color-border-2);color:var(--lay-color-text-1);background-color: var(--lay-color-bg-4)}
.layui-btn-primary:hover{border-color: transparent;color:var(--lay-color-text-2)}
.layui-btn-normal{background-color: var(--lay-color-normal)}
.layui-btn-warm{background-color:var(--lay-color-warning)}
.layui-btn-danger{background-color:var(--lay-color-danger)}
.layui-btn-checked{background-color:var(--lay-color-success)}
.layui-btn-disabled,.layui-btn-disabled:active,.layui-btn-disabled:hover{border-color: var(--lay-color-border-2)!important;background-color: var(--lay-color-bg-2)!important;color: var(--lay-color-text-4)!important}
.layui-btn-group .layui-btn{border-left:1px solid var(--lay-color-border-2)}
.layui-btn-group .layui-btn-primary:hover{border-color:var(--lay-color-border-2);color:var(--lay-color-primary)}
.layui-btn-group .layui-btn-primary:first-child{border-left:1px solid var(--lay-color-gray-5)}
/*表单*/
.layui-input,.layui-select,.layui-textarea{background-color: var(--lay-color-fill-2);color: var(--lay-color-text-2)}
.layui-input:hover,.layui-textarea:hover{border-color: var(--lay-color-border-2)!important}
.layui-input:focus,.layui-textarea:focus{border-color: var(--lay-color-secondary-hover)!important;background-color: var(--lay-color-bg-2);box-shadow: 0 0 0 3px rgba(22, 183, 119, 0.08);}
.layui-input[disabled],.layui-select[disabled],.layui-textarea[disabled],.layui-input.layui-disabled,.layui-textarea.layui-disabled{background-color: var(--lay-color-fill-1);color: var(--lay-color-text-4);border-color: var(--lay-color-border-1)!important;box-shadow: 0 0 0 0;}
.layui-form-danger+.layui-form-select .layui-input,.layui-form-danger:focus{border-color:var(--lay-color-danger)!important;box-shadow: 0 0 0 3px rgba(255, 87, 34, 0.08);}
/* 输入框点缀 */
.layui-input-prefix .layui-icon,.layui-input-split .layui-icon,.layui-input-suffix .layui-icon{color: var(--lay-color-gray-8)}
.layui-input-wrap .layui-input:hover+.layui-input-split{border-color: var(--lay-color-border-2)}
.layui-input-wrap .layui-input[disabled]:hover+.layui-input-split{border-color: var(--lay-color-border-1)}
.layui-input-wrap .layui-input:focus+.layui-input-split{border-color: var(--lay-color-secondary-hover)}
.layui-input-wrap .layui-input.layui-form-danger:focus + .layui-input-split{border-color: var(--lay-color-danger);}
.layui-input-affix .layui-icon{color: var(--lay-color-text-2)}
.layui-input-affix .layui-icon-clear{color:var(--lay-color-text-2)}
.layui-input-affix .layui-icon:hover{color:var(--lay-color-text-3)}
/* 数字输入框动态点缀 */
.layui-input-wrap .layui-input-number .layui-icon-up{border-bottom-color:var(--lay-color-border-1)}
.layui-input-wrap .layui-input[type="number"].layui-input-number-out-of-range{color:var(--lay-color-danger)}
/* 下拉选择 */
.layui-form-select{color:var(--lay-color-text-2)}
.layui-form-select .layui-edge{border-top-color:var(--lay-color-gray-8)}
.layui-form-select dl{border:1px solid var( --lay-color-border-2);background-color: var(--lay-color-bg-5);box-shadow:var(--lay-shadow-2)}
.layui-form-select dl dt{color:var(--lay-color-gray-8)}
.layui-form-select dl dd:hover{background-color:var(--lay-color-active)}
.layui-form-select dl dd.layui-select-tips{color:var(--lay-color-text-2)}
.layui-form-select dl dd.layui-this{background-color: var(--lay-color-active);color: var(--lay-color-text-1)}
.layui-form-select dl dd.layui-disabled,.layui-form-select dl dd:hover.layui-disabled{background-color: var(--lay-color-bg-5)}
.layui-select-none{color:var(--lay-color-black-8)}
.layui-select-disabled .layui-disabled{border-color:var(--lay-color-border-1)!important}
.layui-select-disabled .layui-edge{border-top-color:var(--lay-color-gray-6)}
/* 复选框 */
.layui-form-checkbox{background-color:var(--lay-color-fill-2)}
.layui-form-checkbox>div{background-color:var(--lay-color-fill-3);color:var(--lay-color-text-2)}
.layui-form-checkbox:hover>div{background-color: var(--lay-color-active)}
.layui-form-checkbox>i{background-color: var(--lay-color-fill-1);border-top-color:var(--lay-color-border-1);border-right-color:var(--lay-color-border-1);border-bottom-color:var(--lay-color-border-1);border-left-color:initial;color:var(--lay-color-text-1)}
.layui-form-checkbox:hover>i{border-color:var(--lay-color-border-2);color:var(--lay-color-text-4)}
.layui-form-checked,.layui-form-checked:hover{border-color:var(--lay-color-secondary-active)}
.layui-form-checked>div,.layui-form-checked:hover>div{background-color:var(--lay-color-secondary)}
.layui-form-checked>i,.layui-form-checked:hover>i{color:var(--lay-color-secondary-hover)}
.layui-form-checkbox.layui-checkbox-disabled>div{background-color: var(--lay-color-fill-3) !important;}
/* 复选框-默认风格 */
.layui-form-checkbox[lay-skin=primary]{background-image:none;background-color:initial;border-color:initial!important}
.layui-form-checkbox[lay-skin=primary]>div{background-image:none;background-color:initial;color:var(--lay-color-text-2)}
.layui-form-checkbox[lay-skin=primary]>i{border-color:var(--lay-color-border-1);background-color:var(--lay-color-fill-2)}
.layui-form-checkbox[lay-skin=primary]:hover>i{border-color:var(--lay-color-secondary-hover);color:var(--lay-color-text-1)}
.layui-form-checked[lay-skin=primary]>i{background-color:var(--lay-color-secondary);color:var(--lay-color-text-1);border-color:var(--lay-color-secondary-active)!important}
.layui-checkbox-disabled[lay-skin=primary] >div{background:none!important;color:var(--lay-color-text-4)!important}
.layui-form-checked.layui-checkbox-disabled[lay-skin=primary]>i{background-color:var(--lay-color-fill-1)!important;border-color:var(--lay-color-border-2)!important}
.layui-checkbox-disabled[lay-skin=primary]:hover>i{border-color:var(--lay-color-border-1)}
.layui-form-checkbox[lay-skin="primary"]>.layui-icon-indeterminate:before{background-color: var(--lay-color-secondary-hover);opacity: 1;}
.layui-form-checkbox[lay-skin="primary"]:hover>.layui-icon-indeterminate:before{opacity: 1;}
.layui-form-checkbox[lay-skin="primary"]>.layui-icon-indeterminate{border-color: var(--lay-color-secondary-hover);}
/* 复选框-开关风格 */
.layui-form-switch{border-color:var(--lay-color-border-2);background-color:var(--lay-color-fill-2)}
.layui-form-switch>i{background-color:var(--lay-color-gray-4)}
.layui-form-switch.layui-checkbox-disabled>i{background-color:var(--lay-color-gray-7);}
.layui-form-switch>div{color:var(--lay-color-gray-8)!important}
.layui-form-onswitch{border-color:var(--lay-color-secondary-active);background-color:var(--lay-color-secondary)}
.layui-form-onswitch>i{background-color:var(--lay-color-gray-4)}
.layui-form-onswitch>div{color:var(--lay-color-text-1)!important}
.layui-checkbox-disabled{border-color:var(--lay-color-border-2)!important}
.layui-checkbox-disabled>div{background-color:var(--lay-color-fill-3)!important;color: var(--lay-color-text-4)!important;}
.layui-checkbox-disabled>i{border-color:var(--lay-color-border-2)!important}
.layui-checkbox-disabled:hover>i{color:var(--lay-color-text-1)!important}
.layui-form-switch.layui-checkbox-disabled>div{background-color:initial!important;color: var(--lay-color-text-3)!important;}
/*复选框背景优化*/
.layui-form-checkbox>i:before{opacity:0;filter:alpha(opacity=0)}
.layui-form-checkbox:hover>i:before{opacity:1;filter:alpha(opacity=100)}
.layui-form-checked.layui-checkbox-disabled:hover>i:before,.layui-form-checked:hover>i:before,.layui-form-checked>i:before{opacity:1;filter:alpha(opacity=100)}
.layui-form-checkbox[lay-skin=primary]:hover>i:before{opacity:0;filter:alpha(opacity=0)}
.layui-form-checked[lay-skin=primary]:hover>i:before{opacity:1;filter:alpha(opacity=100)}
.layui-checkbox-disabled:hover>i:before{opacity:0;filter:alpha(opacity=0)}
/*单选框*/
.layui-form-radio>i{color:var(--lay-color-gray-8)}
.layui-form-radio:hover>*,.layui-form-radioed,.layui-form-radioed>i{color:var(--lay-color-secondary)}
.layui-radio-disabled>i{color:var(--lay-color-text-4)!important}
.layui-radio-disabled>*{color:var(--lay-color-text-4)!important}
/* 表单方框风格 */
.layui-form-pane .layui-form-label{background-color:var(--lay-color-bg-2)}
/** 分页 **/
.layui-laypage a,.layui-laypage button,.layui-laypage input,.layui-laypage select,.layui-laypage span{border:1px solid var(--lay-color-border-2)}
.layui-laypage a,.layui-laypage span{background-color: var(--lay-color-bg-2);color: var(--lay-color-text-2)}
.layui-laypage a[data-page]{color:var(--lay-color-text-2)}
.layui-laypage a:hover{color: var(--lay-color-primary)}
.layui-laypage .layui-laypage-spr{color:var(--lay-color-text-3)}
.layui-laypage .layui-laypage-curr em{color: var(--lay-color-white)}
.layui-laypage .layui-laypage-curr .layui-laypage-em{background-color: var(--lay-color-primary)}
.layui-laypage .layui-laypage-skip{color:var(--lay-color-text-3)}
.layui-laypage button,.layui-laypage input{background-color: var(--lay-color-bg-2)}
.layui-laypage input:focus,.layui-laypage select:focus{border-color: var(--lay-color-primary)!important}
/** 流加载 **/
.layui-flow-more{color:var(--lay-color-text-1)}
.layui-flow-more a cite{background-color: var(--lay-color-bg-4);color: var(--lay-color-text-1)}
.layui-flow-more a i{color:var(--lay-color-text-2)}
/** 表格 **/
.layui-table{background-color: var(--lay-color-bg-2);color: var(--lay-color-text-2)}
.layui-table-mend{background-color: var(--lay-color-bg-2)}
.layui-table-click,.layui-table-hover,.layui-table[lay-even] tbody tr:nth-child(even){background-color:var(--lay-color-fill-3)}
.layui-table-checked{background-color: var(--lay-color-fill-2);color: var(--lay-color-text-1)}
.layui-table-checked.layui-table-hover,.layui-table-checked.layui-table-click{background-color: var(--lay-color-fill-3);}
.layui-table td,.layui-table th,.layui-table-col-set,.layui-table-fixed-r,.layui-table-grid-down,.layui-table-header,.layui-table-mend,.layui-table-page,.layui-table-tips-main,.layui-table-tool,.layui-table-total,.layui-table-view,.layui-table[lay-skin=line],.layui-table[lay-skin=row]{border-color: var(--lay-color-border-2)}
.layui-table-view:after {background-color: var(--lay-color-border-2);}
.layui-table-view .layui-table td[data-edit]:hover:after{border:1px solid var(--lay-color-primary-active)}
.layui-table-loading-icon .layui-icon{color:var(--lay-color-gray-8);}
.layui-table-page{background-color: var(--lay-color-bg-2);}
.layui-table-page .layui-laypage a,
.layui-table-page .layui-laypage span{border: none;}
.layui-table-tool{background-color: var(--lay-color-bg-2);}
.layui-table-tool .layui-inline[lay-event]{color:var(--lay-color-text-3);border:1px solid var(--lay-color-border-2)}
.layui-table-tool .layui-inline[lay-event]:hover{border:1px solid var(--lay-color-border-3)}
.layui-table-tool-panel{color: var(--lay-color-text-1); border:1px solid var(--lay-color-border-2);background-color: var(--lay-color-bg-5);box-shadow:var(--lay-shadow-2)}
.layui-table-tool-panel li:hover{background-color:var(--lay-color-active)}
.layui-table-col-set{background-color: var(--lay-color-white)}
.layui-table-sort .layui-table-sort-asc{border-bottom-color:var(--lay-color-gray-8)}
.layui-table-sort .layui-table-sort-asc:hover{border-bottom-color:var(--lay-color-gray-11)}
.layui-table-sort .layui-table-sort-desc{border-top-color:var(--lay-color-gray-8)}
.layui-table-sort .layui-table-sort-desc:hover{border-top-color:var(--lay-color-gray-11)}
.layui-table-sort[lay-sort=asc] .layui-table-sort-asc{border-bottom-color:var(--lay-color-gray-13)}
.layui-table-sort[lay-sort=desc] .layui-table-sort-desc{border-top-color:var(--lay-color-gray-13)}
.layui-table-cell .layui-table-link{color: var(--lay-color-lightblue-5)}
.layui-table-body .layui-none{color:var(--lay-color-gray-8)}
.layui-table-fixed-l{box-shadow:1px 0 8px rgba(0,0,0,1)}
.layui-table-fixed-r{box-shadow:-1px 0 8px rgba(0,0,0,1)}
.layui-table-edit{box-shadow:var(--lay-shadow-1);background-color: var(--lay-color-bg-2)}
.layui-table-edit:focus{border-color:var(--lay-color-secondary)!important}
select.layui-table-edit{border-color:var(--lay-color-border-2)}
.layui-table-grid-down{background-color: var(--lay-color-bg-5);color:var(--lay-color-gray-8)}
.layui-table-grid-down:hover{background-color:var(--lay-color-bg-5)}
/* 单元格多行展开风格 */
.layui-table-cell-c{background-color: var(--lay-color-gray-13);color: var(--lay-color-text-1); border-color: var(--lay-color-border-3);}
.layui-table-cell-c:hover{border-color: var(--lay-color-secondary-hover);}
/* 单元格 TIPS 展开风格 */
body .layui-table-tips .layui-layer-content{box-shadow:var(--lay-shadow-3)}
.layui-table-tips-main{background-color: var(--lay-color-bg-5);color: var(--lay-color-text-3)}
.layui-table-tips-c{background-color:var(--lay-color-gray-13);color: var(--lay-color-text-1)}
.layui-table-tips-c:hover{background-color:var(--lay-color-gray-10)}
/** 文件上传 **/
.layui-upload-choose{color:var(--lay-color-gray-8)}
.layui-upload-drag{border:1px dashed var( --lay-color-border-2);background-color: var(--lay-color-bg-4);color: var(--lay-color-text-2)}
.layui-upload-drag .layui-icon{color: var(--lay-color-primary)}
.layui-upload-drag[lay-over]{border-color: var(--lay-color-primary)}
/* 基础菜单元素 */
.layui-menu{background-color: var(--lay-color-bg-2)}
.layui-menu li{color: var(--lay-color-text-1)}
.layui-menu li:hover{background-color: var(--lay-color-bg-5)}
.layui-menu li.layui-disabled,.layui-menu li.layui-disabled *{color:var(--lay-color-text-4)!important}
.layui-menu .layui-menu-item-group>.layui-menu-body-title{color: var(--lay-color-text-3)}
.layui-menu .layui-menu-item-none{color: var(--lay-color-text-3);}
.layui-menu .layui-menu-item-divider{border-bottom:1px solid var(--lay-color-border-2)}
.layui-menu .layui-menu-item-group:hover,
.layui-menu .layui-menu-item-none:hover,
.layui-menu .layui-menu-item-divider:hover{background: none;}
.layui-menu .layui-menu-item-up>.layui-menu-body-title{color: var(--lay-color-text-1)}
.layui-menu .layui-menu-item-down:hover>.layui-menu-body-title>.layui-icon,.layui-menu .layui-menu-item-up>.layui-menu-body-title:hover>.layui-icon{color: var(--lay-color-text-1)}
.layui-menu .layui-menu-item-checked,.layui-menu .layui-menu-item-checked2{background-color:var(--lay-color-active)!important;color:var(--lay-color-secondary)}
.layui-menu .layui-menu-item-checked a,.layui-menu .layui-menu-item-checked2 a{color:var(--lay-color-secondary)}
.layui-menu .layui-menu-item-checked:after{border-right:3px solid var(--lay-color-secondary)}
.layui-menu-body-title a{color: var(--lay-color-text-1)}
.layui-menu-lg .layui-menu-body-title a:hover,.layui-menu-lg li:hover{color:var(--lay-color-secondary)}
/* 下拉菜单 */
.layui-dropdown{background-color: var(--lay-color-bg-5)}
.layui-dropdown.layui-panel,.layui-dropdown .layui-panel{background-color: var(--lay-color-bg-5);box-shadow: var(--lay-shadow-2)}
.layui-dropdown.layui-panel .layui-menu{background-color: var(--lay-color-bg-5)}
/** 导航菜单 **/
.layui-nav{background-color:var(--lay-color-black-6);color: var(--lay-color-white)}
.layui-nav .layui-nav-item a{color: var(--lay-color-text-1);}
.layui-nav .layui-this:after,.layui-nav-bar{background-color:var(--lay-color-secondary)}
.layui-nav .layui-nav-item a:hover,.layui-nav .layui-this a{color: var(--lay-color-text-1)}
.layui-nav-child{box-shadow:var(--lay-shadow-2);border:1px solid var(--lay-color-border-2);background-color: var(--lay-color-bg-5)}
.layui-nav .layui-nav-child a{color: var(--lay-color-text-1)}
.layui-nav .layui-nav-child a:hover{background-color: var(--lay-color-bg-5);color: var(--lay-color-text-1)}
.layui-nav-child dd.layui-this{background-color: var(--lay-color-bg-5);color: var(--lay-color-text-1)}
.layui-nav-tree .layui-nav-child dd.layui-this,.layui-nav-tree .layui-nav-child dd.layui-this a,.layui-nav-tree .layui-this,.layui-nav-tree .layui-this>a,.layui-nav-tree .layui-this>a:hover{background-color: var(--lay-color-primary);color: var(--lay-color-white)}
.layui-nav-itemed>a,.layui-nav-tree .layui-nav-title a,.layui-nav-tree .layui-nav-title a:hover{color: var(--lay-color-white)!important}
.layui-nav-tree .layui-nav-bar{background-color:var(--lay-color-primary)}
.layui-nav-tree .layui-nav-child{background: none; background-color:rgba(0, 0, 0, .3); border: none; box-shadow: none;}
.layui-nav-tree .layui-nav-child a{color: var(--lay-color-white);color: var(--lay-color-text-1)}
.layui-nav-tree .layui-nav-child a:hover{background: none; color: var(--lay-color-white)}
.layui-nav.layui-bg-gray,.layui-nav-tree.layui-bg-gray{background-color: var(--lay-color-bg-2) !important;color: var(--lay-color-text-1);}
.layui-nav-tree.layui-bg-gray .layui-nav-child{background-color: rgba(0, 0, 0, .3) !important;}
.layui-nav-tree.layui-bg-gray a,.layui-nav.layui-bg-gray .layui-nav-item a{color: var(--lay-color-text-1)}
.layui-nav.layui-bg-gray .layui-nav-child{background-color: var(--lay-color-bg-5);}
.layui-nav-tree.layui-bg-gray .layui-nav-itemed>a{color: var(--lay-color-text-1)!important}
.layui-nav.layui-bg-gray .layui-this a{color:var(--lay-color-secondary)}
.layui-nav-tree.layui-bg-gray .layui-nav-child dd.layui-this,.layui-nav-tree.layui-bg-gray .layui-nav-child dd.layui-this a,.layui-nav-tree.layui-bg-gray .layui-this,.layui-nav-tree.layui-bg-gray .layui-this>a{color:var(--lay-color-secondary)!important}
.layui-nav-tree.layui-bg-gray .layui-nav-bar{background-color:var(--lay-color-secondary)}
/** 面包屑 **/
.layui-breadcrumb a{color:var(--lay-color-gray-7)!important}
.layui-breadcrumb a:hover{color:var(--lay-color-secondary)!important}
.layui-breadcrumb a cite{color:var(--lay-color-gray-8)}
.layui-breadcrumb span[lay-separator]{color:var(--lay-color-gray-7)}
/** Tab 选项卡 **/
.layui-tab .layui-tab-title:after{border-bottom-color: var(--lay-color-border-1);}
.layui-tab-title .layui-this{color: var(--lay-color-text-2)}
.layui-tab-title .layui-this:after{border-bottom-color: var(--lay-color-bg-1)}
.layui-tab-bar{background-color: var(--lay-color-bg-3)}
.layui-tab-more li.layui-this:after{border-bottom-color:var(--lay-color-border-1)}
.layui-tab-title li .layui-tab-close{color:var(--lay-color-gray-8)}
.layui-tab-title li .layui-tab-close:hover{background-color:var(--lay-color-danger);color: var(--lay-color-white)}
.layui-tab-brief>.layui-tab-title .layui-this{color:var( --lay-color-primary)}
.layui-tab-brief>.layui-tab-more li.layui-this:after,.layui-tab-brief>.layui-tab-title .layui-this:after{border-bottom:2px solid var(--lay-color-secondary)}
.layui-tab-card{box-shadow: var(--lay-shadow-1)}
.layui-tab-card>.layui-tab-title{background-color: var(--lay-color-bg-2)}
.layui-tab-card>.layui-tab-title .layui-this{background-color: var(--lay-color-bg-1)}
.layui-tab-card>.layui-tab-title .layui-this:after{border-bottom-color: var(--lay-color-bg-1)}
.layui-tab-card>.layui-tab-more .layui-this{color:var(--lay-color-secondary)}
/** tabs 标签页 **/
.layui-tabs-header:after,
.layui-tabs-scroll:after{border-bottom-color: var(--lay-color-border-1);}
.layui-tabs-card>.layui-tabs-header .layui-this{background-color: transparent;}
.layui-tabs-card>.layui-tabs-header .layui-this:after{border-color: var(--lay-color-border-1); border-bottom-color: var(--lay-color-bg-1);}
.layui-tabs-card.layui-panel>.layui-tabs-header .layui-this:after{border-bottom-color: var(--lay-color-bg-2);}
.layui-tabs-bar .layui-icon{background-color: var(--lay-color-bg-1); color: var(--lay-color-text-2); border-color: var(--lay-color-border-1); box-shadow: 2px 0 5px 0 rgb(0 0 0 / 32%);}
.layui-tabs-bar .layui-icon-next{box-shadow: -2px 0 5px 0 rgb(0 0 0 / 32%);}
/*时间线*/
.layui-timeline-axis{background-color: var(--lay-color-bg-4);color:var(--lay-color-secondary)}
.layui-timeline-axis:hover{color:var(--lay-color-red-6)}
.layui-timeline-item:before{background-color: var(--lay-color-bg-3)}
/*徽章*/
.layui-badge,.layui-badge-dot,.layui-badge-rim{background-color:var(--lay-color-red-6);color: var(--lay-color-white)}
.layui-badge-rim{background-color: var(--lay-color-white);color:var(--lay-color-black-6)}
/* carousel 轮播 */
.layui-carousel{background-color:var(--lay-color-gray-2)}
.layui-carousel>[carousel-item]:before{color:var(--lay-color-gray-8);-moz-osx-font-smoothing:grayscale}
.layui-carousel>[carousel-item]>*{background-color:var(--lay-color-gray-2)}
.layui-carousel-arrow{background-color:rgba(0,0,0,.2);color: var(--lay-color-white)}
.layui-carousel-arrow:hover,.layui-carousel-ind ul:hover{background-color:var(--lay-color-black)}
.layui-carousel[lay-indicator=outside] .layui-carousel-ind ul{background-color:var(--lay-color-black)}
.layui-carousel-ind ul{background-color:rgba(0,0,0,.2)}
.layui-carousel-ind ul li{background-color:var(--lay-color-gray-3);background-color: var(--lay-color-text-3)}
.layui-carousel-ind ul li:hover{background-color: var(--lay-color-white)}
.layui-carousel-ind ul li.layui-this{background-color: var(--lay-color-white)}
/** fixbar **/
.layui-fixbar li{background-color:var(--lay-color-black-5);color: var(--lay-color-text-1)}
/** 表情面板 **/
body .layui-util-face .layui-layer-content{background-color: var(--lay-color-bg-5);color:var(--lay-color-text-2)}
.layui-util-face ul{border:1px solid var(--lay-color-border-3);background-color: var(--lay-color-bg-5);box-shadow:var(--lay-shadow-2)}
.layui-util-face ul li{border:1px solid var(--lay-color-border-2)}
.layui-util-face ul li:hover{border:1px solid var(--lay-color-red-7);background: var(--lay-color-text-1)}
/** 代码文本修饰 **/
.layui-code{border:1px solid var(--lay-color-border-2);background-color: var(--lay-color-bg-white);color: var(--lay-color-text-2)}
/** 穿梭框 **/
.layui-transfer-box,.layui-transfer-header,.layui-transfer-search{border-color: var(--lay-color-border-2)}
.layui-transfer-box{background-color: var(--lay-color-bg-2)}
.layui-transfer-search .layui-icon-search{color:var(--lay-color-gray-8)}
.layui-transfer-active .layui-btn{background-color:var( --lay-color-secondary);border-color:var( --lay-color-secondary);color: var(--lay-color-white)}
.layui-transfer-active .layui-btn-disabled{background-color:var(--lay-color-gray-2);border-color:var(--lay-color-gray-3);color:var(--lay-color-gray-8)}
.layui-transfer-data li:hover{background-color:var(--lay-color-active)}
/* chrome 105 */
.layui-transfer-data li:hover:has([lay-filter="layTransferCheckbox"][disabled]){background-color:var(--lay-color-bg-2)}
.layui-transfer-data .layui-none{color:var(--lay-color-gray-7)}
/** 评分组件 **/
.layui-rate li i.layui-icon{color:var(--lay-color-orange-6)}
/** 颜色选择器 **/
.layui-colorpicker{border:1px solid var(--lay-color-border-1)}
.layui-colorpicker:hover{border-color: var(--lay-color-border-2)}
.layui-colorpicker-trigger-span{border:1px solid var(--lay-color-border-1)}
.layui-colorpicker-trigger-i{color: var(--lay-color-white)}
.layui-colorpicker-trigger-i.layui-icon-close{color:var(--lay-color-black-7)}
.layui-colorpicker-main{background: var(--lay-color-bg-2);border:1px solid var( --lay-color-border-2);box-shadow:var(--lay-shadow-2)}
.layui-colorpicker-basis-white{background:linear-gradient(90deg, #fff,hsla(0,0%,100%,0))} /* danger: 勿改*/
.layui-colorpicker-basis-black{background:linear-gradient(0deg,#000,transparent)} /* danger: 勿改*/
.layui-colorpicker-basis-cursor{border:1px solid var(--lay-color-white)}
.layui-colorpicker-side{background:linear-gradient(linear-gradient(#F00, #FF0, #0F0, #0FF, #00F, #F0F, #F00))} /* danger: 勿改*/
.layui-colorpicker-side-slider{box-shadow:var(--lay-shadow-1);background: var(--lay-color-white);border:1px solid var(--lay-color-gray-2)}
.layui-colorpicker-alpha-slider{box-shadow:var(--lay-shadow-1);background: var(--lay-color-white);border:1px solid var(--lay-color-gray-2)}
.layui-colorpicker-pre.layui-this{box-shadow:var(--lay-shadow-1)}
.layui-colorpicker-pre.selected{box-shadow:var(--lay-shadow-1)}
.layui-colorpicker-main-input input.layui-input{color: var(--lay-color-text-2)}
/** 滑块 **/
.layui-slider{background: var( --lay-color-bg-5)}
.layui-slider-step{background: var(--lay-color-fill-4)}
.layui-slider-wrap-btn{background: var(--lay-color-bg-4)}
.layui-slider-tips{color: var(--lay-color-text-1);background:var(--lay-color-black);box-shadow: var(--lay-shadow-3)}
.layui-slider-tips:after{border-color:var(--lay-color-black) transparent transparent transparent}
.layui-slider-input{border:1px solid var(--lay-color-border-1)}
.layui-slider-input-btn{border-left:1px solid var(--lay-color-border-1)}
.layui-slider-input-btn i{color:var(--lay-color-gray-9)}
.layui-slider-input-btn i:first-child{border-bottom:1px solid var(--lay-color-border-1)}
.layui-slider-input-btn i:hover{color:var(--lay-color-primary)}
/** 树组件 **/
.layui-tree-line .layui-tree-set .layui-tree-set:after{border-top:1px dotted var(--lay-color-gray-7)}
.layui-tree-entry:hover{background-color: var(--lay-color-bg-4)}
.layui-tree-line .layui-tree-entry:hover{background-color:var(--lay-color-black)}
.layui-tree-line .layui-tree-entry:hover .layui-tree-txt{color:var(--lay-color-text-3)}
.layui-tree-entry:hover:has(span.layui-tree-txt.layui-disabled){background-color: transparent !important}
.layui-tree-line .layui-tree-set:before{border-left:1px dotted var(--lay-color-gray-7)}
.layui-tree-iconClick{color:var(--lay-color-gray-7)}
.layui-tree-icon{border:1px solid var(--lay-color-gray-8)}
.layui-tree-icon .layui-icon{color:var(--lay-color-text-1)}
.layui-tree-iconArrow:after{border-color:transparent transparent transparent var(--lay-color-gray-7)}
.layui-tree-txt{color:var(--lay-color-text-2)}
.layui-tree-search{color:var(--lay-color-black-7)}
.layui-tree-btnGroup .layui-icon:hover{color:var(--lay-color-text-2)}
.layui-tree-editInput{background-color:var(--lay-color-fill-2)}
.layui-tree-emptyText{color:var(--lay-color-text-2)}
/*code 不处理*/
.layui-code-view{border:1px solid var(--lay-color-border-1);}
.layui-code-view:not(.layui-code-hl){background-color: var(--lay-color-bg-2);color: var(--lay-color-text-2);}
.layui-code-header{border-bottom: 1px solid var(--lay-color-border-1); background-color: var(--lay-color-bg-2)}
.layui-code-header > .layui-code-header-about{color: var(--lay-color-text-2);}
.layui-code-view:not(.layui-code-hl) .layui-code-ln-side{border-color: var(--lay-color-border-1); background-color: var(--lay-color-bg-2);}
.layui-code-nowrap > .layui-code-ln-side{background: none !important;}
.layui-code-fixbar > span{color: var(--lay-color-text-3);}
.layui-code-fixbar > span:hover{color: var(--lay-color-secondary-hover);}
.layui-code-theme-dark,
.layui-code-theme-dark > .layui-code-header{border-color: rgb(126 122 122 / 15%); background-color: #1f1f1f;}
.layui-code-theme-dark{border-width: 1px; color: #ccc;}
.layui-code-theme-dark > .layui-code-ln-side{border-right-color: #2a2a2a; background: none; color: #6e7681;}
.layui-code-view.layui-code-hl > .layui-code-ln-side{background-color: transparent;}
.layui-code-theme-dark.layui-code-hl,
.layui-code-theme-dark.layui-code-hl > .layui-code-ln-side{border-color: rgb(126 122 122 / 15%);}
.layui-code-full{background-color: var(--lay-color-bg-1)}
/*日期选择器*/
.layui-laydate-header i{color:var(--lay-color-gray-8)}
.laydate-day-holidays:before{color:var(--lay-color-red-6)}
.layui-laydate .layui-this .laydate-day-holidays:before{color: var(--lay-color-white)}
.layui-laydate-footer span{border:1px solid var(--lay-color-border-2);background-color: var(--lay-color-bg-5)}
.layui-laydate-footer span:hover{color:var(--lay-color-secondary)}
.layui-laydate-footer span.layui-laydate-preview{border-color:transparent!important;}
.layui-laydate-footer span.layui-laydate-preview:hover{color:var(--lay-color-text-1) !important}
.layui-laydate-shortcut+.layui-laydate-main{border-left:1px solid var(--lay-color-border-2)}
.layui-laydate .layui-laydate-list{background-color: var(--lay-color-bg-5)}
.layui-laydate-hint{color:var(--lay-color-danger)}
.layui-laydate-range .laydate-main-list-1 .layui-laydate-content,.layui-laydate-range .laydate-main-list-1 .layui-laydate-header{border-left:1px solid var(--lay-color-border-2)}
.layui-laydate,.layui-laydate-hint{border-color: var(--lay-color-border-2);box-shadow:var(--lay-shadow-3);background-color: var(--lay-color-bg-5);color: var(--lay-color-text-1)}
.layui-laydate{box-shadow: var(--lay-shadow-2)}
.layui-laydate-hint{border-color:var(--lay-color-border-1)}
.layui-laydate-header{border-bottom:1px solid var( --lay-color-border-2)}
.layui-laydate-header i:hover,.layui-laydate-header span:hover{color:var(--lay-color-secondary)}
.layui-laydate-content th{color: var(--lay-color-text-1)}
.layui-laydate-content td{color: var(--lay-color-text-1)}
.layui-laydate-content td.laydate-day-now{color:var(--lay-color-secondary)}
.layui-laydate-content td.laydate-day-now:after{border:1px solid var(--lay-color-secondary)}
.layui-laydate-linkage .layui-laydate-content td.laydate-selected>div{background-color:var(--lay-color-green-8);}
.layui-laydate-linkage .laydate-selected:hover>div{background-color:var(--lay-color-green-8)!important}
.layui-laydate-content td>div:hover,.layui-laydate-list li:hover,.layui-laydate-shortcut>li:hover{background-color: var(--lay-color-fill-2);color: var(--lay-color-text-2)}
.layui-laydate-content td.laydate-disabled>div:hover{background-color: var(--lay-color-bg-5);color: var(--lay-color-text-4)}
.laydate-time-list li ol{border:1px solid var(--lay-color-border-2)}
.laydate-time-list>li:hover{background: 0 0;}
.layui-laydate-content .laydate-day-next,.layui-laydate-content .laydate-day-prev{color: var(--lay-color-text-3)}
.layui-laydate-linkage .laydate-selected.laydate-day-next>div,.layui-laydate-linkage .laydate-selected.laydate-day-prev>div{background: none!important}
.layui-laydate-footer{border-top:1px solid var(--lay-color-border-2)}
.layui-laydate-hint{color:var(--lay-color-danger)}
.laydate-day-mark::after{background-color:var(--lay-color-secondary)}
.layui-laydate-footer span[lay-type=date]{color:var(--lay-color-secondary)}
.layui-laydate .layui-this,.layui-laydate .layui-this>div{background-color:var(--lay-color-secondary)!important;color: var(--lay-color-white)!important}
.layui-laydate .laydate-disabled,.layui-laydate .laydate-disabled:hover{color: var(--lay-color-text-4)!important}
.layui-laydate .layui-this.laydate-disabled,.layui-laydate .layui-this.laydate-disabled>div{background-color: var(--lay-color-fill-1) !important;color: var(--lay-color-text-4) !important;}
.laydate-theme-molv .layui-laydate-header{background-color:var(--lay-color-primary)}
.laydate-theme-molv .layui-laydate-header i,.laydate-theme-molv .layui-laydate-header span{color:var(--lay-color-gray-2)}
.laydate-theme-molv .layui-laydate-header i:hover,.laydate-theme-molv .layui-laydate-header span:hover{color: var(--lay-color-white)}
.laydate-theme-molv .layui-laydate-content{border:1px solid var(--lay-color-border-2)}
.laydate-theme-molv .layui-this, .laydate-theme-molv .layui-this>div{background-color: var(--lay-color-primary) !important;}
.laydate-theme-molv .layui-laydate-footer{border:1px solid var(--lay-color-border-2)}
.laydate-theme-grid .laydate-month-list>li,.laydate-theme-grid .laydate-year-list>li,.laydate-theme-grid .layui-laydate-content td,.laydate-theme-grid .layui-laydate-content thead{border:1px solid var(--lay-color-border-2)}
.layui-laydate-linkage.laydate-theme-grid .laydate-selected,.layui-laydate-linkage.laydate-theme-grid .laydate-selected:hover{background-color:var(--lay-color-gray-3)!important;color:var(--lay-color-primary)!important}
.layui-laydate-linkage.laydate-theme-grid .laydate-selected.laydate-day-next,.layui-laydate-linkage.laydate-theme-grid .laydate-selected.laydate-day-prev{color:var(--lay-color-gray-6)!important}
.layui-laydate.laydate-theme-circle .layui-laydate-content table td.layui-this{background-color:transparent!important}
/*layer*/
.layui-layer{background-color: var(--lay-color-bg-3);box-shadow:var(--lay-shadow-3)}
.layui-layer-border{border:1px solid var(--lay-color-border-2);box-shadow:var(--lay-shadow-3)}
.layui-layer-move{background-color: var(--lay-color-bg-5)}
.layui-layer-title{border-bottom:1px solid var(--lay-color-border-2);color: var(--lay-color-text-1)}
.layui-layer-setwin span{color: var(--lay-color-text-1)}
.layui-layer-setwin .layui-layer-min:before{border-bottom-color:var(--lay-color-text-1)}
.layui-layer-setwin .layui-layer-min:hover:before{border-bottom-color:var(--lay-color-info-hover)}
.layui-layer-setwin .layui-layer-max:after,.layui-layer-setwin .layui-layer-max:before{border:1px solid var(--lay-color-text-3)}
.layui-layer-setwin .layui-layer-max:hover:after,.layui-layer-setwin .layui-layer-max:hover:before{border-color:var(--lay-color-info-hover)}
.layui-layer-setwin .layui-layer-maxmin:after,.layui-layer-setwin .layui-layer-maxmin:before{background-color: var(--lay-color-bg-5)}
.layui-layer-setwin .layui-layer-close2{color:var(--lay-color-text-1);background-color:var(--lay-color-gray-10)}
.layui-layer-setwin .layui-layer-close2:hover{background-color:var(--lay-color-normal)}
.layui-layer-btn a{border:1px solid var(--lay-color-border-2);background-color: var( --lay-color-bg-3);color: var(--lay-color-text-2)}
.layui-layer-btn .layui-layer-btn0{border-color: transparent;background-color: var(--lay-color-normal);color: var(--lay-color-text-1)}
.layui-layer-dialog .layui-layer-content .layui-layer-face{color:var(--lay-color-gray-9)}
.layui-layer-dialog .layui-layer-content .layui-icon-tips{color:var(--lay-color-warning)}
.layui-layer-dialog .layui-layer-content .layui-icon-success{color: var(--lay-color-success)}
.layui-layer-dialog .layui-layer-content .layui-icon-error{top: 19px; color: var(--lay-color-danger)}
.layui-layer-dialog .layui-layer-content .layui-icon-question{color: var(--lay-color-warning);}
.layui-layer-dialog .layui-layer-content .layui-icon-lock{color: var(--lay-color-gray-10)}
.layui-layer-dialog .layui-layer-content .layui-icon-face-cry{color:var(--lay-color-danger)}
.layui-layer-dialog .layui-layer-content .layui-icon-face-smile{color:var(--lay-color-success)}
.layui-layer-rim{border:6px solid var(--lay-color-gray-8);border:6px solid var(--lay-color-border-2)}
.layui-layer-msg{border:1px solid var( --lay-color-border-1)}
.layui-layer-hui{background-color: var(--lay-color-bg-3);color: var(--lay-color-text-1)}
.layui-layer-hui .layui-layer-close{color: var(--lay-color-white)}
.layui-layer-loading-icon{color:var(--lay-color-gray-9)}
.layui-layer-loading-2:after,.layui-layer-loading-2:before{border:3px solid var(--lay-color-gray-6)}
.layui-layer-loading-2:after{border-color:transparent;border-left-color: var(--lay-color-normal)}
.layui-layer-tips .layui-layer-content{box-shadow: var(--lay-shadow-3);background-color: var(--lay-color-bg-5);color: var(--lay-color-text-1)}
.layui-layer-tips i.layui-layer-TipsG{border-color:transparent}
.layui-layer-tips i.layui-layer-TipsB,.layui-layer-tips i.layui-layer-TipsT{border-right-color:var(--lay-color-black)}
.layui-layer-tips i.layui-layer-TipsL,.layui-layer-tips i.layui-layer-TipsR{border-bottom-color:var(--lay-color-black)}
.layui-layer-lan .layui-layer-title{background:var(--lay-color-blue-5);color: var(--lay-color-text-1)}
.layui-layer-lan .layui-layer-btn{border-top:1px solid var(--lay-color-border-3)}
.layui-layer-lan .layui-layer-btn a{background: var(--lay-color-white);border-color:var(--lay-color-border-3);color: var(--lay-color-black-7)}
.layui-layer-lan .layui-layer-btn .layui-layer-btn1{background: var(--lay-color-gray-7)}
.layui-layer-molv .layui-layer-title{background:var(--lay-color-layuigreen-6);color: var(--lay-color-text-1)}
.layui-layer-molv .layui-layer-btn a{background:var(--lay-color-layuigreen-6);border-color:var(--lay-color-layuigreen-6)}
.layui-layer-molv .layui-layer-btn .layui-layer-btn1{background:var(--lay-color-gray-7)}
.layui-layer-win10{border-color: var(--lay-color-border-2)}
.layui-layer-win10 .layui-layer-btn{background-color: var(--lay-color-bg-2);border-color: var(--lay-color-border-2)}
.layui-layer-win10.layui-layer-dialog .layui-layer-content{color: var(--lay-color-blue-7)}
.layui-layer-win10 .layui-layer-btn .layui-layer-btn0{border-color: var(--lay-color-blue-9);background-color: var(--lay-color-bg-5);color: var(--lay-color-text-1)}
.layui-layer-win10 .layui-layer-btn .layui-layer-btn1{border-color: var(--lay-color-border-2);background-color: var(--lay-color-bg-5);color: var(--lay-color-text-1)}
.layui-layer-win10 .layui-layer-btn a:hover{background-color: var(--lay-color-blue-10);border-color: var(--lay-color-blue-8)}
.layui-layer-prompt .layui-layer-input{border:1px solid var(--lay-color-border-2);color: var(--lay-color-text-2)}
.layui-layer-tab{box-shadow:var(--lay-shadow-3)}
.layui-layer-tab .layui-layer-title span.layui-this{border-left:1px solid var(--lay-color-border-2);border-right:1px solid var(--lay-color-border-2);background-color: var(--lay-color-bg-3)}
.layui-layer-photos{background: none; box-shadow: none;}
.layui-layer-photos-prev,.layui-layer-photos-next{color:var(--lay-color-gray-9)}
.layui-layer-photos-prev:hover,.layui-layer-photos-next:hover{color:var(--lay-color-text-1)}
.layui-layer-photos-toolbar{background-color:#333;background-color: var(--lay-color-bg-5);color: var(--lay-color-text-1)}
.layui-layer-photos-toolbar *{color: var(--lay-color-text-1)}
.layui-layer-photos-toolbar a:hover{color: var(--lay-color-text-2)}
.layui-layer-photos-header > span:hover{background-color: var(--lay-color-fill-2)}
.layui-layer-tips i.layui-layer-TipsB,.layui-layer-tips i.layui-layer-TipsT{border-right-color: var(--lay-color-bg-5)}
.layui-layer-tips i.layui-layer-TipsL,.layui-layer-tips i.layui-layer-TipsR{border-bottom-color: var(--lay-color-bg-5)}
.layui-layer-prompt .layui-layer-input{border:1px solid var(--lay-color-border-2);color:var(--lay-color-text-1);background-color:var(--lay-color-black)}
.layui-layer-prompt .layui-layer-input:focus{outline:0}
/*fix style*/
.layui-layer-loading{background:0 0;box-shadow:0 0}
.layui-btn-primary{border-color:transparent}
.layui-btn-group .layui-btn:first-child{border-left:none}
.layui-btn-group .layui-btn-primary:hover{border-top-color:transparent; border-bottom-color: transparent;}
.layui-menu li:hover{background-color:var(--lay-color-fill-2)}
.layui-nav-child dd.layui-this{background-color:var(--lay-color-fill-2)}
.layui-nav .layui-nav-child a:hover{background-color:var(--lay-color-fill-2)}
.layui-nav .layui-nav-item a:hover,.layui-nav .layui-this a{background-color: var(--lay-color-fill-2)}
.layui-nav-child dd.layui-this{background-color: var(--lay-color-fill-2)}
.layui-tab-card>.layui-tab-title .layui-this:after{border-bottom-color:var(--lay-color-bg-1)}
.layui-form-select dl dd:hover{background-color:var(--lay-color-fill-2)}
.layui-form-select dl dd.layui-this{background-color:var(--lay-color-fill-2)}
.layui-laypage button{color:var(--lay-color-text-1)}
.layui-table[lay-even] tbody tr:nth-child(even){background-color:var(--lay-color-fill-4)}
.layui-menu .layui-menu-item-checked,.layui-menu .layui-menu-item-checked2{background-color:var(--lay-color-fill-2)!important}
.layui-input-split{background-color: var(--lay-color-bg-2);}
.layui-input-wrap .layui-input-prefix.layui-input-split{border-width: 1px;}
.layui-input-wrap .layui-input-split:has(+.layui-input:hover) {border-color: var(--lay-color-border-2);}
.layui-input-wrap .layui-input-split:has(+.layui-input:focus) {border-color: var(--lay-color-secondary-hover);}
.layui-layer-tab .layui-layer-title span:first-child{border-left: none !important;}
.layui-slider-input.layui-input,
.layui-slider-input .layui-input {background-color: var(--lay-color-bg-2);}

240
web/static/tpl/theme.html Normal file
View File

@@ -0,0 +1,240 @@
<style>
:root{
--ms-color-bg: #FFF;
}
:root.dark{
--ms-color-bg: #000;
}
.ms-color-palette{
font-size:0;
}
.ms-color-palette .ms-color-gradient:nth-child(10n){
margin-right: 20px;
}
.ms-color-palette .ms-color-gradient:nth-child(20n){
display: table-column;
}
.ms-color-gradient{
display: inline-block;
position: relative;
width: 20px;
height:20px;
margin-bottom: 15px;
margin-right: 5px;
transition-duration:0.1s;
}
.ms-color-gradient:hover{
transform:scale(1.5);
z-index: 999;
}
.ms-color-edit-picker>div{
border-color: transparent !important;
}
.ms-color-edit-picker i{
display: none;
}
.ms-color-edit-picker span{
border-color: #5f5f60;
}
.ms-color-edit-picker .layui-colorpicker-trigger-bgcolor{
display: inline;
}
</style>
<div style="padding: 20px;background-color: var(--ms-color-bg);">
<div style="display: flex;justify-content: space-between;align-items: center;">
<div style="display: flex;" >
<label style="display: flex; position: relative; top: 1px; margin-right: 10px;" title="降低饱和度,提高亮度,暗色下更舒适">
深色色板
<input name="colorpicker" type="checkbox" style="height: 20px; width: 20px;">
</label>
<label title="类名/属性名例如 .dark,[theme-mode='dark']">
自定义主题类/属性选择器
<input name="theme-prefix" type="input" style="height: 18px; width: 150px;">
</label>
<i id="theme-prefix-tips" class="layui-icon layui-icon-tips" style="position: relative; top: 3.5px;margin-left: 2px;"></i>
</div>
<div style="display: flex;" >
<span title="重置"><i id="resetTheme" style="font-size: 23px;" class="layui-icon layui-icon-refresh"></i></span>
<span title="下载"><i id="downloadCSS" style="font-size: 23px;margin-left: 10px;" class="layui-icon layui-icon-download-circle"></i></span>
</div>
</div>
<hr>
<div class="ms-color-palette">
<!-- 色板 tpl 生成 -->
</div>
<div class="ms-color-edit">
<!-- 编辑区 tpl 生成 -->
</div>
</div>
<template id="tpl-color-palette">
{{# layui.each(d.ColorPaletteLight, function(key, val){ }}
<div class="ms-color-gradient" style="background-color: var({{- key }});" title="{{- key.replace('--lay-color-','') }}"></div>
{{# }) }}
</template>
<template id="tpl-color-editable">
{{# layui.each(d.editable, function(key, val){ }}
<div style="display: flex; align-items: center; height:30px">
<div>{{= key }}</div>
<div style="flex: 1 1 auto;"></div>
<input type="text" name="color" value="{{= val }}" placeholder=""
style="text-align: right;height:28px;width: 150px;background-color: transparent;border-color: transparent;">
<div class="ms-color-edit-picker" lay-options="{color: '{{= val }}', format: '{{- /^rgb/.test(val) ? 'rgb':'hex' }}' }" data-key="{{= key}}"></div>
</div>
{{# }) }}
</template>
<template id="tpl-theme-prefix-example">
<pre class="layui-code code-demo" lay-options="{}" style="margin: 0; padding: 0;">
/** .dark通过改变 html 标签的类名切换主题*/
:root{ :root.dark{
--color-bg: #000; --color-bg: #000;
} ==> }
.lay-card{ .dark .lay-card{}
color: #FFF; color: #FFF;
} }
/** js */
// 设置为暗色主题
document.documentElement.classList.add('dark')
// 恢复亮色主题
document.documentElement.classList.remove('dark')
// 切换亮/暗主题
document.documentElement.classList.toggle('dark')
----------------------------------------------------------
/** [theme-mode='dark'],通过改变 html 标签上 theme-mode 的属性切换主题*/
:root{ :root[theme-mode='dark']{
--color-bg: #000; --color-bg: #000;
} ==> }
.lay-card{ [theme-mode='dark'] .lay-card{}
color: #FFF; color: #FFF;
} }
/** js */
// 设置为暗色主题
document.documentElement.setAttribute('theme-mode', 'dark')
// 恢复亮色主题
document.documentElement.removeAttribute('theme-mode');
</pre>
</template>
<script src="static/lib/less.js"></script>
<script>
layui.use(async ()=> {
const {jquery:$,laytpl,colorpicker,layer} = layui;
const originalData=await (await fetch('./assets/themes.json')).json()
let customTheme = {};
laytpl($('#tpl-color-palette').html()).render(originalData,function(str) {
$('.ms-color-palette').html(str);
});
laytpl($('#tpl-color-editable').html()).render(originalData,function(str) {
$('.ms-color-edit').html(str);
renderColorPicker();
});
$('input[name=colorpicker]').on('click',function(){
applyColorPalette(this.checked)
})
$('#resetTheme').on('click',function(){
resetTheme()
$('input[name=colorpicker]').prop('checked',false)
})
$('#downloadCSS').on('click',function(){
dropdownCSS()
})
$('#theme-prefix-tips').hover(function(){
layer.tips($('#tpl-theme-prefix-example').html(),this,{
tips: 3,
time: false,
area: ['700px','auto'],
success: function(layero){
layui.code({elem: '.code-demo'});
layero.find('.layui-layer-content').css({padding:0,margin:0})
layero.css('top','50px') //阻止反转
}
});
},function(){
layer.closeLast('tips');
})
function renderColorPicker(){
colorpicker.render({
elem: '.ms-color-edit-picker',
alpha: true,
change: function(color) {
const elem=this.elem
elem.prev().val(color)
applyTheme(elem.data('key'),color)
},
done: function(color) {
const elem=this.elem
elem.prev().val(color)
applyTheme(elem.data('key'),color)
},
close: function(color){
const elem=this.elem
elem.prev().val(color)
applyTheme(elem.data('key'),color)
}
});
}
function resetTheme() {
customTheme={}
addStyle('demo-customTheme',getCSS(customTheme))
renderColorPicker()
}
function applyTheme(key,val){
customTheme = {...customTheme,...{[key]:val} }
addStyle('demo-customTheme',getCSS(customTheme))
}
function applyColorPalette(isDark=false){
customTheme={...customTheme, ...originalData[isDark ? 'ColorPaletteDark' : 'ColorPaletteLight']}
addStyle('demo-customTheme',getCSS(customTheme))
}
function getCSS(cssVarsObj){
return `:root {\n ${
Object.entries(cssVarsObj)
.map(([key,val])=> ` ${key}: ${val};`)
.join('\n')
}\n}`
}
async function dropdownCSS(){
const hasPrefix = $('input[name="theme-prefix"]').val()
const overrideCSS = await(await fetch('./static/src/override.css')).text()
const varsCSS = getCSS({...originalData.Default, ...customTheme})
let finalCSS=`
${hasPrefix
? varsCSS.replace(':root',`:root${hasPrefix}`)
: varsCSS}\n
${hasPrefix
? `${hasPrefix}{${overrideCSS}}`
: overrideCSS}`;
// css-next 插件太大,暂时用 less
finalCSS = (await window.less.render(finalCSS)).css
const alink=document.createElement("a")
alink.download='layui-theme-dark-custom.css'
alink.href=URL.createObjectURL(new Blob([finalCSS]))
document.body.appendChild(alink)
alink.click()
document.body.removeChild(alink)
}
function addStyle(id,cssStr) {
var el=document.getElementById(id)||document.createElement('style')
if(!el.isConnected) {
el.type='text/css';
el.id=id;
document.head.appendChild(el);
}
el.textContent=cssStr;
}
})
</script>

View File

@@ -0,0 +1,412 @@
{{ define "apps.html" }}
<section>
<h2>应用管理</h2>
<div class="layui-btn-container" style="margin:12px 0">
<button class="layui-btn" id="btnAddApp"><i class="layui-icon layui-icon-add-1"></i> 新增应用</button>
<button class="layui-btn layui-btn-danger" id="btnBatchDeleteApps"><i class="layui-icon layui-icon-delete"></i> 批量删除</button>
<button class="layui-btn layui-btn-normal" id="btnBatchEnableApps"><i class="layui-icon layui-icon-ok-circle"></i> 批量启用</button>
<button class="layui-btn layui-btn-warm" id="btnBatchDisableApps"><i class="layui-icon layui-icon-close-fill"></i> 批量禁用</button>
</div>
<div class="layui-card" style="margin-top:12px">
<div class="layui-card-header">筛选</div>
<div class="layui-card-body">
<form class="layui-form layui-form-pane" id="appFilterForm" lay-filter="appFilterForm">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">搜索</label>
<div class="layui-input-inline">
<input type="text" name="search" placeholder="应用名称/UUID" autocomplete="off" class="layui-input" />
</div>
</div>
<div class="layui-inline">
<button type="button" class="layui-btn" id="btnSearchApps">查询</button>
<button type="button" class="layui-btn layui-btn-primary" id="btnResetApps">重置</button>
</div>
</div>
</form>
</div>
</div>
<div class="layui-card" style="margin-top:12px">
<div class="layui-card-header">应用列表</div>
<div class="layui-card-body">
<table id="appsTable" lay-filter="appsTableFilter"></table>
<script type="text/html" id="tpl-apps-ops">
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>
<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="del">删除</a>
</script>
<script type="text/html" id="tpl-apps-status">
{{`{{# if(d.status === 1) { }}`}}
<span class="layui-badge layui-bg-green">启用</span>
{{`{{# } else { }}`}}
<span class="layui-badge">禁用</span>
{{`{{# } }}`}}
</script>
</div>
</div>
<!-- 隐藏的表单弹层内容:新增/编辑应用 -->
<div id="appFormModal" style="display:none;padding:16px">
<form class="layui-form layui-form-pane" id="appForm">
<input type="hidden" name="id" />
<div class="layui-form-item">
<label class="layui-form-label">应用名称</label>
<div class="layui-input-block">
<input type="text" name="name" placeholder="请输入应用名称" autocomplete="off" class="layui-input" lay-verify="required" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">版本号</label>
<div class="layui-input-block">
<input type="text" name="version" placeholder="请输入版本号默认1.0.0" autocomplete="off" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">状态</label>
<div class="layui-input-block">
<select name="status">
<option value="1" selected>启用</option>
<option value="0">禁用</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">强制更新</label>
<div class="layui-input-block">
<select name="force_update">
<option value="0" selected>不开启</option>
<option value="1">开启</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">更新方式</label>
<div class="layui-input-block">
<select name="download_type" lay-filter="downloadTypeChange">
<option value="0" selected>不启用更新</option>
<option value="1">自动更新</option>
<option value="2">手动下载</option>
</select>
</div>
</div>
<div class="layui-form-item" id="downloadUrlItem">
<label class="layui-form-label">下载地址</label>
<div class="layui-input-block">
<input type="text" name="download_url" placeholder="请输入下载地址" autocomplete="off" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button type="submit" class="layui-btn" lay-submit lay-filter="appFormSubmit">提交</button>
<button type="button" class="layui-btn layui-btn-primary" id="btnCancelApp">取消</button>
</div>
</div>
</form>
</div>
<script>
layui.use(['table', 'form', 'layer', 'element'], function() {
const table = layui.table;
const form = layui.form;
const layer = layui.layer;
const $ = layui.$;
// 格式化时间函数
function formatDateTime(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString();
}
// 渲染表格
const appsTable = table.render({
elem: '#appsTable',
id: 'appsTable',
url: '/admin/api/apps/list',
parseData: function(res) {
// 后端返回的数据结构处理
return {
code: res.code,
msg: res.msg || '',
count: res.count || 0,
data: res.data || []
};
},
request: {
pageName: 'page', // 页码的参数名称默认page
limitName: 'page_size' // 每页数据量的参数名称默认limit
},
method: 'GET',
page: true,
limit: 20,
limits: [10, 20, 50, 100],
loading: true,
done: function(res, curr, count) {
// 表格渲染完成后的回调
},
cols: [[
{ type: 'checkbox', width: 50 },
{ field: 'id', title: 'ID', width: 80, sort: true },
{ field: 'name', title: '应用名称', minWidth: 180 },
{ field: 'uuid', title: 'UUID', minWidth: 320 },
{ field: 'version', title: '版本', width: 100 },
{
field: 'status',
title: '状态',
width: 100,
templet: (d) => {
if (d.status === 1) return '<span style="color: #5FB878;">启用</span>';
return '<span style="color: #FF5722;">禁用</span>';
}
},
{
field: 'secret',
title: '密钥',
minWidth: 320,
templet: (d) => '<span style="font-family: monospace;">' + d.secret + '</span>'
},
{
field: 'created_at',
title: '创建时间',
width: 180,
templet: (d) => formatDateTime(d.created_at)
},
{ fixed: 'right', title: '操作', toolbar: '#tpl-apps-ops', width: 120 }
]]
});
// 搜索功能
$('#btnSearchApps').on('click', function() {
const search = $('input[name="search"]').val();
appsTable.reload({
where: {
search: search
},
page: {
curr: 1
}
});
});
// 重置搜索
$('#btnResetApps').on('click', function() {
$('#appFilterForm')[0].reset();
appsTable.reload({
where: {},
page: {
curr: 1
}
});
});
// 新增应用
$('#btnAddApp').on('click', function() {
$('#appForm')[0].reset();
$('input[name="id"]').val('');
layer.open({
type: 1,
title: '新增应用',
content: $('#appFormModal'),
area: ['500px', '460px'],
btn: false,
shadeClose: false
});
form.render();
});
// 监听更新方式切换(保留事件监听器以备将来扩展)
form.on('select(downloadTypeChange)', function(data) {
// 下载地址字段现在始终显示,无需切换显示状态
});
// 表单提交
form.on('submit(appFormSubmit)', function(data) {
const isEdit = data.field.id !== '';
const url = isEdit ? '/admin/api/apps/update' : '/admin/api/apps/create';
// 转换字段类型为正确的数据类型
const formData = {
...data.field,
status: parseInt(data.field.status) || 0,
download_type: parseInt(data.field.download_type) || 0,
force_update: parseInt(data.field.force_update) || 0
};
// 如果是编辑模式确保id也是整数
if (isEdit) {
formData.id = parseInt(data.field.id);
}
$.ajax({
url: url,
type: 'POST',
data: JSON.stringify(formData),
contentType: 'application/json',
success: function(res) {
if (res.code === 0) {
layer.msg(res.msg, {icon: 1});
layer.closeAll();
appsTable.reload();
} else {
layer.msg(res.msg || '操作失败', {icon: 2});
}
},
error: function(xhr) {
layer.msg(xhr.responseText || '操作失败', {icon: 2});
}
});
return false;
});
// 取消按钮
$('#btnCancelApp').on('click', function() {
layer.closeAll();
});
// 表格工具栏事件
table.on('tool(appsTableFilter)', function(obj) {
const data = obj.data;
if (obj.event === 'edit') {
// 编辑
$('#appForm')[0].reset();
$('input[name="id"]').val(data.id);
$('input[name="name"]').val(data.name);
$('input[name="version"]').val(data.version);
$('select[name="status"]').val(data.status);
$('select[name="download_type"]').val(data.download_type || 0);
$('input[name="download_url"]').val(data.download_url || '');
$('select[name="force_update"]').val(data.force_update || 0);
layer.open({
type: 1,
title: '编辑应用',
content: $('#appFormModal'),
area: ['500px', '460px'],
btn: false,
shadeClose: false
});
form.render();
} else if (obj.event === 'del') {
// 删除
layer.confirm('确定删除该应用吗?', {icon: 3, title: '提示'}, function(index) {
$.ajax({
url: '/admin/api/apps/delete',
type: 'POST',
data: JSON.stringify({id: data.id}),
contentType: 'application/json',
success: function(res) {
if (res.code === 0) {
layer.msg(res.msg, {icon: 1});
appsTable.reload();
} else {
layer.msg(res.msg || '删除失败', {icon: 2});
}
},
error: function(xhr) {
layer.msg(xhr.responseText || '删除失败', {icon: 2});
}
});
layer.close(index);
});
}
});
// 批量删除
$('#btnBatchDeleteApps').on('click', function() {
const checkStatus = table.checkStatus('appsTable');
const data = checkStatus.data;
if (data.length === 0) {
layer.msg('请选择要删除的应用', {icon: 2});
return;
}
layer.confirm('确定删除选中的 ' + data.length + ' 个应用吗?', {icon: 3, title: '提示'}, function(index) {
const ids = data.map(item => item.id);
$.ajax({
url: '/admin/api/apps/batch_delete',
type: 'POST',
data: JSON.stringify({ids: ids}),
contentType: 'application/json',
success: function(res) {
if (res.code === 0) {
layer.msg(res.msg, {icon: 1});
appsTable.reload();
} else {
layer.msg(res.msg || '批量删除失败', {icon: 2});
}
},
error: function(xhr) {
layer.msg(xhr.responseText || '批量删除失败', {icon: 2});
}
});
layer.close(index);
});
});
// 批量启用
$('#btnBatchEnableApps').on('click', function() {
const checkStatus = table.checkStatus('appsTable');
const data = checkStatus.data;
if (data.length === 0) {
layer.msg('请选择要启用的应用', {icon: 2});
return;
}
const ids = data.map(item => item.id);
$.ajax({
url: '/admin/api/apps/batch_update_status',
type: 'POST',
data: JSON.stringify({ids: ids, status: 1}),
contentType: 'application/json',
success: function(res) {
if (res.code === 0) {
layer.msg(res.msg, {icon: 1});
appsTable.reload();
} else {
layer.msg(res.msg || '批量启用失败', {icon: 2});
}
},
error: function(xhr) {
layer.msg(xhr.responseText || '批量启用失败', {icon: 2});
}
});
});
// 批量禁用
$('#btnBatchDisableApps').on('click', function() {
const checkStatus = table.checkStatus('appsTable');
const data = checkStatus.data;
if (data.length === 0) {
layer.msg('请选择要禁用的应用', {icon: 2});
return;
}
const ids = data.map(item => item.id);
$.ajax({
url: '/admin/api/apps/batch_update_status',
type: 'POST',
data: JSON.stringify({ids: ids, status: 0}),
contentType: 'application/json',
success: function(res) {
if (res.code === 0) {
layer.msg(res.msg, {icon: 1});
appsTable.reload();
} else {
layer.msg(res.msg || '批量禁用失败', {icon: 2});
}
},
error: function(xhr) {
layer.msg(xhr.responseText || '批量禁用失败', {icon: 2});
}
});
});
});
</script>
</section>
{{ end }}

View File

@@ -0,0 +1,415 @@
{{ define "card_types.html" }}
<section>
<h2>卡密类型管理</h2>
<div class="layui-btn-container" style="margin:12px 0">
<button class="layui-btn" id="btnAddCardType"><i class="layui-icon layui-icon-add-1"></i> 新增类型</button>
<button class="layui-btn layui-btn-normal" id="btnBatchEnableCardTypes"><i class="layui-icon layui-icon-ok-circle"></i> 批量启用</button>
<button class="layui-btn layui-btn-warm" id="btnBatchDisableCardTypes"><i class="layui-icon layui-icon-close-fill"></i> 批量禁用</button>
<button class="layui-btn layui-btn-danger" id="btnBatchDeleteCardTypes"><i class="layui-icon layui-icon-delete"></i> 批量删除</button>
</div>
<div class="layui-card" style="margin-top:12px">
<div class="layui-card-header">筛选</div>
<div class="layui-card-body">
<form class="layui-form layui-form-pane" id="cardTypeFilterForm" lay-filter="cardTypeFilterForm">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">名称</label>
<div class="layui-input-inline">
<input type="text" name="keyword" placeholder="卡密类型名称" autocomplete="off" class="layui-input" />
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">状态</label>
<div class="layui-input-inline">
<select name="status">
<option value="">全部</option>
<option value="1">启用</option>
<option value="0">禁用</option>
</select>
</div>
</div>
<div class="layui-inline">
<button type="button" class="layui-btn" id="btnSearchCardTypes">查询</button>
<button type="button" class="layui-btn layui-btn-primary" id="btnResetCardTypes">重置</button>
</div>
</div>
</form>
</div>
</div>
<div class="layui-card" style="margin-top:12px">
<div class="layui-card-header">卡密类型列表</div>
<div class="layui-card-body">
<table id="cardTypesTable" lay-filter="cardTypesTableFilter"></table>
<script type="text/html" id="tpl-cardtypes-ops">
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>
<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="del">删除</a>
</script>
</div>
</div>
<!-- 隐藏的表单弹层内容 -->
<div id="cardTypeFormModal" style="display:none;padding:16px">
<!-- 参考demo表单2样式添加layui-form-pane类实现方框风格 -->
<form class="layui-form layui-form-pane" id="cardTypeForm">
<input type="hidden" name="id" />
<div class="layui-form-item">
<label class="layui-form-label">名称</label>
<div class="layui-input-block">
<input type="text" name="name" required lay-verify="required" placeholder="请输入类型名称" autocomplete="off" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">状态</label>
<div class="layui-input-block">
<select name="status">
<option value="1">启用</option>
<option value="0">禁用</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">登录</label>
<div class="layui-input-block">
<input type="text" name="login_types" placeholder="请输入登录方式名称,多个用逗号分隔" class="layui-input" />
</div>
</div>
<!-- 操作按钮已移除,统一由 layer.open 的 btn 控制“提交/取消” -->
<!-- 操作按钮移除:统一由 layer.open 的 btn 控制“提交/取消” -->
</form>
</div>
</section>
<script>
layui.use(['table', 'form', 'layer'], () => {
const { table, form, layer, $ } = layui;
let currentFormLayerIndex; // 保存当前表单弹窗的索引
// 渲染表格
const cardTypesTable = table.render({
elem: '#cardTypesTable',
url: '/admin/api/card_types/list',
method: 'GET',
page: true,
limit: 20,
limits: [10, 20, 50, 100],
loading: true,
cols: [[
{ type: 'checkbox' },
{ field: 'id', title: 'ID', width: 80, sort: true },
{ field: 'name', title: '名称' },
{
field: 'status',
title: '状态',
width: 100,
templet: (d) => {
return d.status === 1
? '<span class="layui-badge layui-bg-green">启用</span>'
: '<span class="layui-badge">禁用</span>';
}
},
{ field: 'login_types', title: '登录方式' },
{
field: 'created_at',
title: '创建时间',
width: 180,
templet: (d) => {
return formatDateTime(d.created_at);
}
},
{
field: 'updated_at',
title: '更新时间',
width: 180,
templet: (d) => {
return formatDateTime(d.updated_at);
}
},
{ title: '操作', toolbar: '#tpl-cardtypes-ops', width: 150, fixed: 'right' }
]],
parseData: (res) => {
// 后端已返回正确格式,直接使用
return {
"code": res.code,
"msg": res.msg || '',
"count": res.data ? res.data.total : 0,
"data": res.data ? res.data.items : []
};
},
request: {
pageName: 'page',
limitName: 'page_size'
},
where: {}
});
// 格式化日期时间
const formatDateTime = (dateStr) => {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.getFullYear() + '-' +
String(date.getMonth() + 1).padStart(2, '0') + '-' +
String(date.getDate()).padStart(2, '0') + ' ' +
String(date.getHours()).padStart(2, '0') + ':' +
String(date.getMinutes()).padStart(2, '0') + ':' +
String(date.getSeconds()).padStart(2, '0');
};
// 监听表格工具条
table.on('tool(cardTypesTableFilter)', (obj) => {
const { data, event } = obj;
if (event === 'edit') {
editCardType(data);
} else if (event === 'del') {
deleteCardType(data.id);
}
});
// 新增卡密类型
$('#btnAddCardType').on('click', () => {
showCardTypeForm();
});
// 显示表单弹窗(统一使用 layer.open 的按钮作为确认/取消)
const showCardTypeForm = (data = null) => {
const title = data ? '编辑卡密类型' : '新增卡密类型';
currentFormLayerIndex = layer.open({
type: 1,
title: title,
content: $('#cardTypeFormModal'),
area: ['500px', '300px'],
btn: ['提交', '取消'],
btnAlign: 'c',
yes: () => {
// 点击“提交”时,执行统一的提交方法
doCardTypeSubmit();
},
btn2: (index) => {
// 点击“取消”时,关闭当前弹窗
layer.close(index);
},
success: () => {
// 成功打开弹窗后渲染表单,并根据是否为编辑模式进行回填
form.render();
if (data) {
// 编辑模式,填充数据
$('#cardTypeForm input[name="id"]').val(data.id);
$('#cardTypeForm input[name="name"]').val(data.name);
$('#cardTypeForm select[name="status"]').val(data.status);
$('#cardTypeForm input[name="login_types"]').val(data.login_types);
form.render('select');
} else {
// 新增模式,清空表单
$('#cardTypeForm')[0].reset();
$('#cardTypeForm input[name="id"]').val('');
form.render();
}
}
});
};
// 编辑卡密类型
const editCardType = (data) => {
showCardTypeForm(data);
};
// 提交表单(通过 layer.open 的“提交”按钮触发)
const doCardTypeSubmit = () => {
// 读取表单数据
const idValue = $('#cardTypeForm input[name="id"]').val();
const formData = {
id: idValue ? parseInt(idValue) : 0,
name: $('#cardTypeForm input[name="name"]').val().trim(),
status: parseInt($('#cardTypeForm select[name="status"]').val()),
login_types: $('#cardTypeForm input[name="login_types"]').val().trim()
};
// 校验必填项
if (!formData.name) {
layer.msg('请输入类型名称', { icon: 2 });
return;
}
// 根据是否存在 id 判断是创建还是更新
const url = formData.id ? '/admin/api/card_types/update' : '/admin/api/card_types/create';
const loadIndex = layer.load(2);
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
})
.then(res => res.json())
.then(res => {
layer.close(loadIndex);
if (res.code === 0) {
layer.msg(res.msg || (formData.id ? '更新成功' : '创建成功'), { icon: 1 });
layer.close(currentFormLayerIndex);
cardTypesTable.reload();
} else {
layer.msg(res.msg || '操作失败', { icon: 2 });
}
})
.catch(() => {
layer.close(loadIndex);
layer.msg('网络错误,请重试', { icon: 2 });
});
};
// 取消按钮已移除,统一由 layer.open 的“取消”按钮处理
// 取消按钮已移除:统一由 layer.open 的“取消”按钮处理
// 删除卡密类型
const deleteCardType = (id) => {
layer.confirm('确定要删除这个卡密类型吗?', {
icon: 3,
title: '提示'
}, (index) => {
layer.close(index);
const loadIndex = layer.load(2);
fetch('/admin/api/card_types/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ id: id })
})
.then(response => response.json())
.then(data => {
layer.close(loadIndex);
if (data.code === 0) {
layer.msg(data.msg, { icon: 1 });
cardTypesTable.reload();
} else {
layer.msg(data.msg, { icon: 2 });
}
})
.catch(error => {
layer.close(loadIndex);
layer.msg('网络错误,请重试', { icon: 2 });
});
});
};
// 批量删除
$('#btnBatchDeleteCardTypes').on('click', () => {
const checkStatus = table.checkStatus('cardTypesTable');
if (checkStatus.data.length === 0) {
layer.msg('请选择要删除的数据', { icon: 2 });
return;
}
layer.confirm(`确定要删除选中的 ${checkStatus.data.length} 条数据吗?`, {
icon: 3,
title: '提示'
}, (index) => {
layer.close(index);
const ids = checkStatus.data.map(item => item.id);
const loadIndex = layer.load(2);
fetch('/admin/api/card_types/batch_delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids: ids })
})
.then(response => response.json())
.then(data => {
layer.close(loadIndex);
if (data.code === 0) {
layer.msg(data.msg, { icon: 1 });
cardTypesTable.reload();
} else {
layer.msg(data.msg, { icon: 2 });
}
})
.catch(error => {
layer.close(loadIndex);
layer.msg('网络错误,请重试', { icon: 2 });
});
});
});
// 批量启用
$('#btnBatchEnableCardTypes').on('click', () => {
batchUpdateStatus('/admin/api/card_types/batch_enable', '启用');
});
// 批量禁用
$('#btnBatchDisableCardTypes').on('click', () => {
batchUpdateStatus('/admin/api/card_types/batch_disable', '禁用');
});
// 批量更新状态的通用函数
const batchUpdateStatus = (url, action) => {
const checkStatus = table.checkStatus('cardTypesTable');
if (checkStatus.data.length === 0) {
layer.msg('请选择要操作的数据', { icon: 2 });
return;
}
layer.confirm(`确定要${action}选中的 ${checkStatus.data.length} 条数据吗?`, {
icon: 3,
title: '提示'
}, (index) => {
layer.close(index);
const ids = checkStatus.data.map(item => item.id);
const loadIndex = layer.load(2);
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids: ids })
})
.then(response => response.json())
.then(data => {
layer.close(loadIndex);
if (data.code === 0) {
layer.msg(data.msg, { icon: 1 });
cardTypesTable.reload();
} else {
layer.msg(data.msg, { icon: 2 });
}
})
.catch(error => {
layer.close(loadIndex);
layer.msg('网络错误,请重试', { icon: 2 });
});
});
};
// 搜索功能
$('#btnSearchCardTypes').on('click', () => {
const keyword = $('#cardTypeFilterForm input[name="keyword"]').val();
const status = $('#cardTypeFilterForm select[name="status"]').val();
cardTypesTable.reload({
where: {
keyword: keyword,
status: status
},
page: {
curr: 1
}
});
});
// 重置搜索
$('#btnResetCardTypes').on('click', () => {
$('#cardTypeFilterForm')[0].reset();
form.render();
cardTypesTable.reload({
where: {},
page: {
curr: 1
}
});
});
});
</script>
{{ end }}

View File

@@ -0,0 +1,771 @@
{{ define "cards.html" }}
<section>
<h2>卡密管理</h2>
<div class="layui-btn-container" style="margin:12px 0">
<button class="layui-btn" id="btnAddCard"><i class="layui-icon layui-icon-add-1"></i> 新增卡密</button>
<button class="layui-btn layui-btn-danger" id="btnBatchDeleteCards"><i class="layui-icon layui-icon-delete"></i> 批量删除</button>
<button class="layui-btn layui-btn-normal" id="btnBatchEnableCards"><i class="layui-icon layui-icon-ok-circle"></i> 设为未用</button>
<button class="layui-btn layui-btn-warm" id="btnBatchDisableCards"><i class="layui-icon layui-icon-close-fill"></i> 设为已用</button>
<!-- 新增:导出卡密按钮 -->
<button class="layui-btn layui-btn-primary" id="btnExportCards"><i class="layui-icon layui-icon-export"></i> 导出卡密</button>
<!-- 新增:导出选中卡密按钮 -->
<button class="layui-btn layui-btn-primary" id="btnExportSelectedCards"><i class="layui-icon layui-icon-export"></i> 导出选中</button>
</div>
<div class="layui-card" style="margin-top:12px">
<div class="layui-card-header">筛选</div>
<div class="layui-card-body">
<form class="layui-form layui-form-pane" id="cardFilterForm" lay-filter="cardFilterForm">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">卡密</label>
<div class="layui-input-inline">
<input type="text" name="keyword" placeholder="卡号/批次/备注/任务号" autocomplete="off" class="layui-input" />
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">类型</label>
<div class="layui-input-inline">
<select name="card_type" id="filterCardTypeSelect">
<option value="">全部</option>
</select>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">状态</label>
<div class="layui-input-inline">
<select name="status">
<option value="">全部</option>
<option value="0">未使用</option>
<option value="1">已使用</option>
</select>
</div>
</div>
<div class="layui-inline">
<button type="button" class="layui-btn" id="btnSearchCards">查询</button>
<button type="button" class="layui-btn layui-btn-primary" id="btnResetCards">重置</button>
</div>
</div>
</form>
</div>
</div>
<div class="layui-card" style="margin-top:12px">
<div class="layui-card-header">卡密列表</div>
<div class="layui-card-body">
<table id="cardsTable" lay-filter="cardsTableFilter"></table>
<script type="text/html" id="tpl-cards-ops">
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>
<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="del">删除</a>
</script>
</div>
</div>
<!-- 隐藏的表单弹层内容:新增卡密 -->
<div id="cardFormModal" style="display:none;padding:16px">
<form class="layui-form layui-form-pane" id="cardForm">
<input type="hidden" name="id" />
<div class="layui-form-item">
<label class="layui-form-label">前缀</label>
<div class="layui-input-block">
<input type="text" name="prefix" placeholder="可选,生成卡号时使用的前缀" autocomplete="off" class="layui-input" />
</div>
</div>
<!-- 新增生成数量默认1最大500位置在前缀之后、大小写之前 -->
<div class="layui-form-item">
<label class="layui-form-label">数量</label>
<div class="layui-input-block">
<input type="number" name="count" min="1" max="500" value="1" placeholder="一次生成的数量默认1最大500" class="layui-input" />
</div>
</div>
<!-- 新增:生成大小写选项(默认小写),位置在长度之前 -->
<div class="layui-form-item">
<label class="layui-form-label">规则</label>
<div class="layui-input-block">
<select name="uppercase">
<option value="lower" selected>小写</option>
<option value="upper">大写</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">长度</label>
<div class="layui-input-block">
<input type="number" name="length" min="1" max="64" value="18" placeholder="生成卡号的总长度包含前缀默认18" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">类型</label>
<div class="layui-input-block">
<select name="card_type" id="cardTypeSelect">
<option value="">请选择类型</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">状态</label>
<div class="layui-input-block">
<select name="status">
<option value="0">未使用</option>
<option value="1">已使用</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">备注</label>
<div class="layui-input-block">
<textarea name="remark" placeholder="可填写备注信息" class="layui-textarea"></textarea>
</div>
</div>
<!-- 移除:内置“操作/提交/取消”按钮,统一由 layer.open 的 btn 控制 -->
</form>
</div>
<!-- 隐藏的表单弹层内容:编辑卡密 -->
<div id="cardEditFormModal" style="display:none;padding:16px">
<form class="layui-form layui-form-pane" id="cardEditForm">
<input type="hidden" name="id" />
<div class="layui-form-item">
<label class="layui-form-label">类型</label>
<div class="layui-input-block">
<select name="card_type" id="cardEditTypeSelect">
<option value="">请选择类型</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">状态</label>
<div class="layui-input-block">
<select name="status">
<option value="0">未使用</option>
<option value="1">已使用</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">任务号</label>
<div class="layui-input-block">
<input type="text" name="task_no" placeholder="可选,支持填写/清空任务号" autocomplete="off" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">备注</label>
<div class="layui-input-block">
<textarea name="remark" placeholder="可填写备注信息" class="layui-textarea"></textarea>
</div>
</div>
<!-- 移除:内置“操作/提交/取消”按钮,统一由 layer.open 的 btn 控制 -->
</form>
</div>
<!-- 新增:导出条件弹窗(隐藏) -->
<div id="cardExportModal" style="display:none;padding:16px">
<form class="layui-form layui-form-pane" id="cardExportForm" lay-filter="cardExportForm">
<div class="layui-form-item">
<label class="layui-form-label">状态</label>
<div class="layui-input-block">
<select name="status">
<option value="">全部</option>
<option value="0">未使用</option>
<option value="1">已使用</option>
<option value="2">禁用</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">类型</label>
<div class="layui-input-block">
<select name="card_type" id="exportCardTypeSelect">
<option value="">全部</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">批次</label>
<div class="layui-input-block">
<input type="text" name="batch" placeholder="按批次模糊匹配" autocomplete="off" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">备注</label>
<div class="layui-input-block">
<input type="text" name="remark" placeholder="按备注模糊匹配" autocomplete="off" class="layui-input" />
</div>
</div>
</form>
</div>
</section>
<script>
layui.use(['table', 'form', 'layer'], () => {
const { table, form, layer, $ } = layui;
let currentFormLayerIndex; // 保存当前表单弹窗的索引
let cardTypes = []; // 存储卡密类型数据
// 中文注释:以下三个标志用于协调类型列表和表格渲染的先后关系,避免出现“未知类型”
let cardTypesLoaded = false; // 类型列表是否已加载完成
let tableFirstRendered = false; // 表格是否已完成首次渲染
let tableReloadedAfterTypes = false; // 类型加载后是否已触发表格的二次渲染
// 格式化时间的辅助函数
const formatDateTime = (dateStr) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
return String(date.getFullYear()) + '-' +
String(date.getMonth() + 1).padStart(2, '0') + '-' +
String(date.getDate()).padStart(2, '0') + ' ' +
String(date.getHours()).padStart(2, '0') + ':' +
String(date.getMinutes()).padStart(2, '0') + ':' +
String(date.getSeconds()).padStart(2, '0');
};
// 获取卡密类型名称
// 中文注释:根据 card_type_id 在缓存的 cardTypes 中查找对应的类型名称;
// 为避免后端返回的 id 与前端数据在类型上不一致(字符串/数字)导致匹配失败,这里统一转换为数字再比较
const getCardTypeName = (cardTypeId) => {
const idNum = Number(cardTypeId);
const cardType = cardTypes.find(type => Number(type.id) === idNum);
return cardType ? cardType.name : '未知类型';
};
// 渲染表格
const cardsTable = table.render({
elem: '#cardsTable',
id: 'cardsTable',
url: '/admin/api/cards/list',
parseData: function(res) {
// 后端返回的数据结构:{items, total, page, page_size, pages}
return {
code: res.code,
msg: res.msg || '',
count: res.data ? res.data.total : 0,
data: res.data ? res.data.items : []
};
},
request: {
pageName: 'page', // 页码的参数名称默认page
limitName: 'page_size' // 每页数据量的参数名称默认limit
},
method: 'GET',
page: true,
limit: 20,
limits: [10, 20, 50, 100],
loading: true,
// 中文注释:表格首次渲染完成后,如果类型已经加载,则进行一次刷新以正确显示类型名称
done: function() {
if (!tableFirstRendered) {
tableFirstRendered = true;
if (cardTypesLoaded && !tableReloadedAfterTypes) {
tableReloadedAfterTypes = true;
cardsTable.reload();
}
}
},
cols: [[
{ type: 'checkbox', width: 50 },
{ field: 'id', title: 'ID', width: 80, sort: true },
{ field: 'card_number', title: '卡号', minWidth: 150 },
{
field: 'card_type_id',
title: '类型',
width: 100,
templet: (d) => d.card_type_name || getCardTypeName(d.card_type_id)
},
{
field: 'status',
title: '状态',
width: 80,
templet: (d) => {
if (d.status === 0) return '<span style="color: #5FB878;">未使用</span>';
if (d.status === 1) return '<span style="color: #FF5722;">已使用</span>';
return '<span style="color: #999;">禁用</span>';
}
},
{
field: 'task_no',
title: '任务号',
minWidth: 140,
templet: (d) => d.task_no || '-'
},
{ field: 'batch', title: '批次', minWidth: 60 },
{ field: 'remark', title: '备注', minWidth: 100 },
{
field: 'used_at',
title: '使用时间',
width: 180,
templet: (d) => formatDateTime(d.used_at)
},
{
field: 'created_at',
title: '创建时间',
width: 180,
templet: (d) => formatDateTime(d.created_at)
},
{ fixed: 'right', title: '操作', toolbar: '#tpl-cards-ops', width: 120 }
]]
});
// 加载卡密类型数据
const loadCardTypes = () => {
fetch('/admin/api/cards/types?all=1')
.then(response => response.json())
.then(data => {
if (data.code === 0) {
cardTypes = data.data || [];
// 填充筛选下拉框
const filterSelect = $('#filterCardTypeSelect');
filterSelect.empty().append('<option value="">全部</option>');
cardTypes.forEach(type => {
if (type.status === 1) { // 只显示启用的类型
filterSelect.append(`<option value="${type.id}">${type.name}</option>`);
}
});
// 填充新增表单下拉框
const cardTypeSelect = $('#cardTypeSelect');
cardTypeSelect.empty().append('<option value="">请选择类型</option>');
cardTypes.forEach(type => {
if (type.status === 1) { // 只显示启用的类型
cardTypeSelect.append(`<option value="${type.id}">${type.name}</option>`);
}
});
// 填充编辑表单下拉框
const cardEditTypeSelect = $('#cardEditTypeSelect');
cardEditTypeSelect.empty().append('<option value="">请选择类型</option>');
cardTypes.forEach(type => {
if (type.status === 1) { // 只显示启用的类型
cardEditTypeSelect.append(`<option value="${type.id}">${type.name}</option>`);
}
});
// 新增:填充导出弹窗下拉框(显示全部状态的类型,方便条件筛选)
const exportTypeSelect = $('#exportCardTypeSelect');
exportTypeSelect.empty().append('<option value="">全部</option>');
cardTypes.forEach(type => {
exportTypeSelect.append(`<option value="${type.id}">${type.name}</option>`);
});
form.render('select');
// 中文注释:标记类型加载完成;如表格已首次渲染,则进行一次性刷新以正确显示类型名称
cardTypesLoaded = true;
if (tableFirstRendered && !tableReloadedAfterTypes) {
tableReloadedAfterTypes = true;
cardsTable.reload();
}
// 卡密类型加载完成,表格会根据需要自动进行一次刷新
}
})
.catch(error => {
console.error('加载卡密类型失败:', error);
});
};
// 初始化加载卡密类型
loadCardTypes();
// 监听表格工具条
table.on('tool(cardsTableFilter)', (obj) => {
const { data, event } = obj;
if (event === 'edit') {
editCard(data);
} else if (event === 'del') {
deleteCard(data.id);
}
});
// 新增卡密
$('#btnAddCard').on('click', () => {
showCardForm();
});
// 显示新增卡密表单弹窗
// 中文注释:弹出新增/编辑表单的公共方法,采用 layer.open + btn/yes/btn2 的“确认框风格”
// data 为空表示新增;存在表示编辑。通过 yes 回调直接调用提交流程函数。
const showCardForm = (data = null) => {
const title = data ? '编辑卡密' : '新增卡密';
const modalId = data ? '#cardEditFormModal' : '#cardFormModal';
const formId = data ? '#cardEditForm' : '#cardForm';
const areaHeight = data ? '420px' : '600px';
currentFormLayerIndex = layer.open({
type: 1,
title: title,
content: $(modalId),
area: ['500px', areaHeight],
btn: ['提交', '取消'],
btnAlign: 'c',
yes: () => {
if (data) {
doEditCardSubmit();
} else {
doCreateCardSubmit();
}
return false;
},
btn2: (index) => {
layer.close(index);
},
success: () => {
form.render();
if (data) {
$(formId + ' input[name="id"]').val(data.id);
$(formId + ' select[name="card_type"]').val(data.card_type_id);
$(formId + ' select[name="status"]').val(data.status);
$(formId + ' textarea[name="remark"]').val(data.remark || '');
// 中文注释:编辑模式下,回填已有的任务号(若无则为空字符串)
$(formId + ' input[name="task_no"]').val(data.task_no || '');
form.render('select');
} else {
$(formId)[0].reset();
$(formId + ' input[name="id"]').val('');
// 中文注释:新增模式下显式清空任务号,避免出现上一次编辑残留
$(formId + ' input[name="task_no"]').val('');
form.render();
}
}
});
};
// 编辑卡密
const editCard = (data) => {
showCardForm(data);
};
// 提交新增卡密表单
// 提交逻辑函数化,供弹窗按钮直接调用,避免依赖模板内按钮
// 中文注释:提交“新增卡密”表单,完成校验、请求与反馈
const doCreateCardSubmit = () => {
const uppercaseValue = $('#cardForm select[name="uppercase"]').val();
const formData = {
prefix: $('#cardForm input[name="prefix"]').val() || '',
count: parseInt($('#cardForm input[name="count"]').val()) || 1,
uppercase: uppercaseValue === 'upper',
length: parseInt($('#cardForm input[name="length"]').val()) || 18,
card_type_id: parseInt($('#cardForm select[name="card_type"]').val()),
status: parseInt($('#cardForm select[name="status"]').val()),
remark: $('#cardForm textarea[name="remark"]').val() || ''
};
// 校验
if (!formData.card_type_id) {
layer.msg('请选择卡密类型', { icon: 2 });
return;
}
if (formData.count < 1 || formData.count > 500) {
layer.msg('生成数量必须在1-500之间', { icon: 2 });
return;
}
if (formData.length < 1 || formData.length > 64) {
layer.msg('卡号长度必须在1-64之间', { icon: 2 });
return;
}
const loadIndex = layer.load(2);
fetch('/admin/api/cards/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
})
.then(response => response.json())
.then(data => {
layer.close(loadIndex);
if (data.code === 0) {
layer.msg(data.msg, { icon: 1 });
layer.close(currentFormLayerIndex);
cardsTable.reload();
} else {
layer.msg(data.msg, { icon: 2 });
}
})
.catch(error => {
layer.close(loadIndex);
console.error('新增卡密失败:', error);
layer.msg('网络错误,请重试', { icon: 2 });
});
};
// 中文注释:提交“编辑卡密”表单,完成校验、请求与反馈
const doEditCardSubmit = () => {
const idValue = $('#cardEditForm input[name="id"]').val();
const taskNoRaw = $('#cardEditForm input[name="task_no"]').val();
const hasTaskNoField = true; // 中文注释:该字段始终存在,通过值是否为空决定清空或设置
const formData = {
id: idValue ? parseInt(idValue) : 0,
card_type_id: parseInt($('#cardEditForm select[name="card_type"]').val()),
status: parseInt($('#cardEditForm select[name="status"]').val()),
remark: $('#cardEditForm textarea[name="remark"]').val() || ''
};
// 当任务号输入框有值或被清空时,也要传递 task_no 字段(允许清空)
if (hasTaskNoField) {
formData.task_no = (taskNoRaw || '').trim();
}
// 校验
if (!formData.id) {
layer.msg('卡密ID不能为空', { icon: 2 });
return;
}
if (!formData.card_type_id) {
layer.msg('请选择卡密类型', { icon: 2 });
return;
}
const loadIndex = layer.load(2);
fetch('/admin/api/cards/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
})
.then(response => response.json())
.then(data => {
layer.close(loadIndex);
if (data.code === 0) {
layer.msg(data.msg, { icon: 1 });
layer.close(currentFormLayerIndex);
cardsTable.reload();
} else {
layer.msg(data.msg, { icon: 2 });
}
})
.catch(error => {
layer.close(loadIndex);
console.error('编辑卡密失败:', error);
layer.msg('网络错误,请重试', { icon: 2 });
});
};
// 删除卡密
const deleteCard = (id) => {
layer.confirm('确定要删除这个卡密吗?', {
icon: 3,
title: '提示'
}, (index) => {
layer.close(index);
const loadIndex = layer.load(2);
fetch('/admin/api/cards/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ id: parseInt(id) })
})
.then(response => response.json())
.then(data => {
layer.close(loadIndex);
if (data.code === 0) {
layer.msg(data.msg, { icon: 1 });
cardsTable.reload();
} else {
layer.msg(data.msg, { icon: 2 });
}
})
.catch(error => {
layer.close(loadIndex);
console.error('删除卡密失败:', error);
layer.msg('网络错误,请重试', { icon: 2 });
});
});
};
// 批量删除卡密
$('#btnBatchDeleteCards').on('click', () => {
const checkStatus = table.checkStatus('cardsTable');
const data = checkStatus.data;
if (data.length === 0) {
layer.msg('请选择要删除的卡密', { icon: 2 });
return;
}
layer.confirm(`确定要删除选中的 ${data.length} 个卡密吗?`, {
icon: 3,
title: '批量删除确认'
}, (index) => {
layer.close(index);
const loadIndex = layer.load(2);
const ids = data.map(item => item.id);
fetch('/admin/api/cards/batch_delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids: ids })
})
.then(response => response.json())
.then(data => {
layer.close(loadIndex);
if (data.code === 0) {
layer.msg(data.msg, { icon: 1 });
cardsTable.reload();
} else {
layer.msg(data.msg, { icon: 2 });
}
})
.catch(error => {
layer.close(loadIndex);
console.error('批量删除卡密失败:', error);
layer.msg('网络错误,请重试', { icon: 2 });
});
});
});
// 批量启用卡密
$('#btnBatchEnableCards').on('click', () => {
batchUpdateStatus(0, '设为未用');
});
// 批量禁用卡密
$('#btnBatchDisableCards').on('click', () => {
batchUpdateStatus(1, '设为已用');
});
// 批量更新状态
const batchUpdateStatus = (status, statusText) => {
const checkStatus = table.checkStatus('cardsTable');
const data = checkStatus.data;
if (data.length === 0) {
layer.msg('请选择要操作的卡密', { icon: 2 });
return;
}
layer.confirm(`确定要${statusText}选中的 ${data.length} 个卡密吗?`, {
icon: 3,
title: `批量${statusText}确认`
}, (index) => {
layer.close(index);
const loadIndex = layer.load(2);
const ids = data.map(item => item.id);
fetch('/admin/api/cards/batch_update_status', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids: ids, status: status })
})
.then(response => response.json())
.then(data => {
layer.close(loadIndex);
if (data.code === 0) {
layer.msg(data.msg, { icon: 1 });
cardsTable.reload();
} else {
layer.msg(data.msg, { icon: 2 });
}
})
.catch(error => {
layer.close(loadIndex);
console.error(`批量${statusText}卡密失败:`, error);
layer.msg('网络错误,请重试', { icon: 2 });
});
});
};
// 搜索功能
$('#btnSearchCards').on('click', () => {
const formData = form.val('cardFilterForm');
const where = {};
if (formData.card_type) {
where.card_type_id = formData.card_type;
}
if (formData.status !== '') {
where.status = formData.status;
}
if (formData.keyword && formData.keyword.trim() !== '') {
// 中文注释:将关键字作为 keyword 传给后端,由后端在 card_number、remark、batch 三个字段中进行模糊匹配
where.keyword = formData.keyword.trim();
}
table.reload('cardsTable', { where, page: { curr: 1 } });
});
$('#btnResetCards').on('click', () => {
$('#cardFilterForm')[0].reset();
form.render();
table.reload('cardsTable', { where: {}, page: { curr: 1 } });
});
// =============== 导出卡密逻辑 ===============
// 显示“导出卡密”弹窗
// 中文注释:弹出导出条件选择弹窗,允许管理员按状态/类型/批次/备注筛选导出
const showExportDialog = () => {
layer.open({
type: 1,
title: '导出卡密',
content: $('#cardExportModal'),
area: ['520px', '360px'],
btn: ['导出', '取消'],
btnAlign: 'c',
yes: (index) => {
doExportCards();
layer.close(index); // 关闭导出弹窗
layer.msg('卡密导出中...', { icon: 1 });
return false;
},
success: () => {
form.render();
}
});
};
// 绑定按钮事件
$('#btnExportCards').on('click', () => {
showExportDialog();
});
// 导出选中卡密
// 中文注释导出当前表格中选中的卡密无需弹窗筛选直接根据选中的卡密ID进行导出
$('#btnExportSelectedCards').on('click', () => {
const checkStatus = table.checkStatus('cardsTable');
const data = checkStatus.data;
if (data.length === 0) {
layer.msg('请选择要导出的卡密', { icon: 2 });
return;
}
layer.confirm(`确定要导出选中的 ${data.length} 个卡密吗?`, {
icon: 3,
title: '导出选中确认'
}, (index) => {
layer.close(index);
const ids = data.map(item => item.id);
const params = new URLSearchParams();
params.set('ids', ids.join(','));
const url = '/admin/api/cards/export_selected?' + params.toString();
triggerDownload(url);
layer.msg(`正在导出 ${data.length} 个卡密...`, { icon: 1 });
});
});
// 执行导出
// 中文注释根据表单条件拼接导出URL并以下载方式触发导出CSV 文件)
const doExportCards = () => {
const formData = form.val('cardExportForm');
const params = new URLSearchParams();
if (formData.status !== '') params.set('status', formData.status);
if (formData.card_type) params.set('card_type_id', formData.card_type);
if (formData.batch && formData.batch.trim() !== '') params.set('batch', formData.batch.trim());
if (formData.remark && formData.remark.trim() !== '') params.set('remark', formData.remark.trim());
const url = '/admin/api/cards/export' + (params.toString() ? ('?' + params.toString()) : '');
triggerDownload(url);
};
// 触发下载
// 中文注释:通过创建临时 <a> 元素点击,保持当前页面不跳转,触发后端附件下载
const triggerDownload = (url) => {
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
});
</script>
{{ end }}

View File

@@ -0,0 +1,236 @@
{{ define "dashboard.html" }}
<section>
<h2>系统信息</h2>
<div class="layui-row layui-col-space15" style="margin-top:12px">
<div class="layui-col-md6">
<div class="layui-card">
<div class="layui-card-header">基本信息</div>
<div class="layui-card-body">
<div class="system-info-grid">
<div class="system-info-item">
<div class="system-info-label">版本</div>
<div class="system-info-value">{{ .Version }}</div>
</div>
<div class="system-info-item">
<div class="system-info-label">运行模式</div>
<div class="system-info-value">{{ .Mode }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="layui-col-md6">
<div class="layui-card">
<div class="layui-card-header">运行状态</div>
<div class="layui-card-body">
<div class="system-info-grid">
<div class="system-info-item">
<div class="system-info-label">数据库</div>
<div class="system-info-value">{{ .DBType }}</div>
</div>
<div class="system-info-item">
<div class="system-info-label">运行时长</div>
<div class="system-info-value">{{ .Uptime }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- 卡密统计区域 -->
<section style="margin-top:16px">
<div class="layui-row layui-col-space15">
<!-- 当日卡密统计 -->
<div class="layui-col-md6">
<div class="layui-card">
<div class="layui-card-header">当日卡密统计 <span class="layui-badge layui-bg-blue" style="margin-left:8px">总数:<span id="today-total">-</span></span></div>
<div class="layui-card-body">
<div id="chart-today-by-status" style="width:100%;height:320px"></div>
</div>
</div>
</div>
<!-- 所有卡密统计 -->
<div class="layui-col-md6">
<div class="layui-card">
<div class="layui-card-header">所有卡密统计 <span class="layui-badge layui-bg-blue" style="margin-left:8px">总数:<span id="all-total">-</span></span></div>
<div class="layui-card-body">
<div id="chart-all-by-status" style="width:100%;height:320px"></div>
</div>
</div>
</div>
</div>
<!-- 30天走势图 -->
<div class="layui-row layui-col-space15" style="margin-top:16px">
<div class="layui-col-md12">
<div class="layui-card">
<div class="layui-card-header">近30天卡密走势</div>
<div class="layui-card-body">
<div id="chart-trend-30days" style="width:100%;height:360px"></div>
</div>
</div>
</div>
</div>
</section>
<script>
// 仪表盘统计脚本(采用箭头函数与中文注释)
layui.use(['layer', 'util'], function(){
const layer = layui.layer;
const util = layui.util;
const $ = layui.$;
// 全局引用ECharts CDN 地址
const echartsCdn = 'https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js';
// 工具函数:加载 ECharts 库(若已加载则直接回调)
// 功能:通过全局的 loadScript 方法按需加载图表库,避免重复加载
const ensureECharts = (cb) => {
if (window.echarts) { cb && cb(); return; }
if (typeof loadScript === 'function') {
loadScript(echartsCdn, () => cb && cb());
} else {
// 兜底:直接插入 <script>
const s = document.createElement('script');
s.src = echartsCdn;
s.onload = () => cb && cb();
document.head.appendChild(s);
}
};
// 工具函数:状态码 -> 名称 映射
// 说明卡密状态映射0=未使用1=已使用2=禁用
const getStatusText = (s) => {
const map = {0:'未使用',1:'已使用',2:'禁用'};
const k = Number(s);
return map[k] ?? String(s);
};
// 工具函数:状态码 -> 颜色 映射(与徽章风格一致,尽量贴近 Layui 配色)
const getStatusColor = (s) => {
switch (Number(s)) {
case 0: return '#1E9FFF'; // 蓝色 - 未使用
case 1: return '#5FB878'; // 绿色 - 已使用
case 2: return '#FF5722'; // 红色 - 禁用
default: return '#909399'; // 灰色 - 默认
}
};
// 函数:渲染饼图
// 说明:接收状态分布对象(键为状态码,值为数量),绘制环形图
const renderPie = (domId, byStatus) => {
const el = document.getElementById(domId);
if (!el) return;
const chart = echarts.init(el);
const codes = [0,1,2]; // 卡密状态0=未使用1=已使用2=禁用
const data = codes.map(code => ({
name: getStatusText(code),
value: Number((byStatus && byStatus[code]) || 0),
itemStyle: { color: getStatusColor(code) }
}));
chart.setOption({
tooltip: { trigger: 'item' },
legend: { top: 'bottom' },
series: [{
name: '按状态分布',
type: 'pie',
radius: ['38%', '68%'],
avoidLabelOverlap: true,
label: { formatter: '{b}: {c} ({d}%)' },
data
}]
});
// 自适应
window.addEventListener('resize', () => chart.resize());
return chart;
};
// 函数:渲染 30 天折线图
// 说明三条序列total/used/unused对应后台返回的数组
const renderTrend = (domId, trend) => {
const el = document.getElementById(domId);
if (!el) return;
const chart = echarts.init(el);
const dates = (trend && trend.dates) || [];
const total = (trend && trend.total) || [];
const used = (trend && trend.used) || [];
const unused = (trend && trend.unused) || [];
chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['总数', '已使用', '未使用'] },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', boundaryGap: false, data: dates },
yAxis: { type: 'value' },
series: [
{ name: '总数', type: 'line', smooth: true, data: total, itemStyle: { color: '#909399' } },
{ name: '已使用', type: 'line', smooth: true, data: used, itemStyle: { color: getStatusColor(1) } },
{ name: '未使用', type: 'line', smooth: true, data: unused, itemStyle: { color: getStatusColor(0) } }
]
});
window.addEventListener('resize', () => chart.resize());
return chart;
};
// 函数:拉取概览并渲染
// 说明:请求 /admin/api/cards/stats_overview更新总数文本并渲染两个饼图
const loadAndRenderOverview = () => {
$.get('/admin/api/cards/stats_overview', (res) => {
if (!res || res.code !== 0) { layer.msg(res && res.msg ? res.msg : '获取统计概览失败'); return; }
const data = res.data || {};
$('#today-total').text((data.today && data.today.total) ?? '-');
$('#all-total').text((data.all && data.all.total) ?? '-');
// 渲染饼图
renderPie('chart-today-by-status', data.today ? data.today.by_status : {});
renderPie('chart-all-by-status', data.all ? data.all.by_status : {});
});
};
// 函数:拉取 30 天数据并渲染折线图
// 说明:请求 /admin/api/cards/trend_30days渲染趋势图
const loadAndRenderTrend = () => {
$.get('/admin/api/cards/trend_30days', (res) => {
if (!res || res.code !== 0) { layer.msg(res && res.msg ? res.msg : '获取30天趋势失败'); return; }
renderTrend('chart-trend-30days', res.data || {});
});
};
// 函数:刷新基本信息和运行状态
// 说明:请求后台获取最新的系统信息并更新页面显示
const refreshSystemInfo = () => {
$.get('/admin/api/system/info', (res) => {
if (res && res.code === 0 && res.data) {
const data = res.data;
// 更新运行时长
if (data.uptime) {
$('.system-info-item').each(function() {
const label = $(this).find('.system-info-label').text();
if (label === '运行时长') {
$(this).find('.system-info-value').text(data.uptime);
}
});
}
}
}).fail(() => {
console.log('获取系统信息失败');
});
};
// 入口:确保 ECharts 已加载后开始渲染
ensureECharts(() => {
loadAndRenderOverview();
loadAndRenderTrend();
// 立即刷新一次系统信息
refreshSystemInfo();
});
});
</script>
{{ end }}

View File

@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{{ .Title }} - {{ .SystemName }}</title>
<link rel="stylesheet" href="/static/css/admin.css" />
<script type="module" src="./static/lib/include.js"></script>
</head>
<body>
<div class="layui-layout layui-layout-admin" id="app">
<div class="layui-header">
<!-- 头部区域可配合layui 已有的水平导航) -->
<ul class="layui-nav layui-layout-left">
<!-- 移动端显示 -->
<li class="layui-nav-item layui-show-xs-inline-block" lay-header-event="menuLeft">
<i class="layui-icon layui-icon-spread-left"></i>
</li>
</ul>
<ul class="layui-nav layui-layout-right">
<!-- 刷新页面按钮 -->
<li class="layui-nav-item" lay-unselect>
<a href="javascript:;" id="refresh-btn" style="background-color: unset" title="刷新页面">
<i class="layui-icon layui-icon-refresh-3" style="font-size: 20px"></i>
</a>
</li>
<li class="layui-nav-item">
<i id="change-theme" class="layui-icon layui-icon-theme" style="font-size: 20px"></i>
</li>
<li class="layui-nav-item" lay-unselect>
<a href="javascript:;" id="logout-btn" style="background-color: unset" title="退出登录">
<i class="layui-icon layui-icon-logout" style="font-size: 20px"></i>
</a>
</li>
</ul>
</div>
<div class="layui-side layui-bg-black">
<div class="layui-side-scroll">
<!-- 左侧导航区域 -->
<div class="layui-logo layui-bg-black logo-enhanced">{{ .SystemName }}</div>
<ul class="layui-nav layui-nav-tree" lay-shrink="all" lay-unselect lay-filter="nav-side" id="ws-nav-side">
<li class="layui-nav-item">
<a class="" href="javascript:;">系统管理</a>
<dl class="layui-nav-child">
<dd><a data-path="dashboard" href="javascript:;">仪表盘</a></dd>
<dd><a data-path="user" href="javascript:;">个人资料</a></dd>
<dd><a data-path="settings" href="javascript:;">系统设置</a></dd>
</dl>
</li>
<li class="layui-nav-item">
<a href="javascript:;">应用管理</a>
<dl class="layui-nav-child">
<dd><a data-path="apps" href="javascript:;">应用列表</a></dd>
</dl>
</li>
<li class="layui-nav-item">
<a href="javascript:;">卡密管理</a>
<dl class="layui-nav-child">
<dd><a data-path="logintypes" href="javascript:;">登录类型</a></dd>
<dd><a data-path="cardtypes" href="javascript:;">卡密类型</a></dd>
<dd><a data-path="cards" href="javascript:;">卡密列表</a></dd>
</dl>
</li>
</ul>
</div>
</div>
<div class="layui-body">
<!-- 内容主体区域 -->
<wc-include id="router-view" allow-scripts></wc-include>
</div>
<div class="layui-footer">{{ .FooterText }}</div>
</div>
<script type="module" src="./static/js/admin.js"></script>
</body>
</html>

View File

@@ -0,0 +1,259 @@
{{/* 管理员登录页面模板使用layui构建的登录界面 */}}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>{{ .Title }}</title>
<link rel="stylesheet" href="https://unpkg.com/layui@2.10.1/dist/css/layui.css">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
width: 100%;
max-width: 400px;
margin: 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.login-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 30px 20px;
text-align: center;
color: #fff;
}
.login-header h1 {
margin: 0;
font-size: 24px;
font-weight: 300;
}
.login-header p {
margin: 8px 0 0;
opacity: 0.8;
font-size: 14px;
}
.login-form {
padding: 40px 30px;
}
.layui-form-item {
margin-bottom: 25px;
}
.layui-input {
border: 1px solid #e6e6e6;
border-radius: 4px;
padding: 12px 15px;
font-size: 14px;
transition: all 0.3s;
}
.layui-input:focus {
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
}
.layui-btn-fluid {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 4px;
padding: 12px;
font-size: 16px;
letter-spacing: 1px;
transition: all 0.3s;
}
.layui-btn-fluid:hover {
transform: translateY(-1px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
/* 修复登录按钮文字垂直位置偏下使用flex进行垂直水平居中并设置固定高度 */
.login-btn {
display: flex;
align-items: center;
justify-content: center;
height: 44px;
padding: 0 16px;
line-height: normal; /* 避免与高度不一致导致的文字偏移 */
font-weight: 500;
}
.error-msg {
color: #ff5722;
font-size: 12px;
margin-top: 5px;
display: none;
}
.login-footer {
text-align: center;
padding: 20px;
color: #999;
font-size: 12px;
border-top: 1px solid #f0f0f0;
}
/* 响应式设计 - 移动端适配 */
@media (max-width: 768px) {
.login-container {
margin: 10px;
border-radius: 4px;
}
.login-header {
padding: 25px 15px;
}
.login-header h1 {
font-size: 20px;
}
.login-form {
padding: 30px 20px;
}
.layui-form-item {
margin-bottom: 20px;
}
}
@media (max-width: 480px) {
.login-container {
margin: 5px;
border-radius: 0;
min-height: calc(100vh - 10px);
display: flex;
flex-direction: column;
}
.login-header {
padding: 20px 15px;
}
.login-header h1 {
font-size: 18px;
}
.login-form {
padding: 25px 15px;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.layui-input {
padding: 15px;
font-size: 16px; /* 防止iOS缩放 */
}
.login-btn {
height: 48px;
font-size: 16px;
}
}
/* 超小屏幕适配 */
@media (max-width: 320px) {
.login-form {
padding: 20px 10px;
}
.login-header {
padding: 15px 10px;
}
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-header">
<h1>{{ .SystemName }}</h1>
<p>管理员登录</p>
</div>
<div class="login-form">
<form class="layui-form" id="loginForm">
<div class="layui-form-item">
<div class="layui-input-wrap">
<div class="layui-input-prefix">
<i class="layui-icon layui-icon-username"></i>
</div>
<input type="text" name="username" placeholder="请输入用户名" lay-verify="required" lay-reqtext="请输入用户名" class="layui-input" autocomplete="off">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-wrap">
<div class="layui-input-prefix">
<i class="layui-icon layui-icon-password"></i>
</div>
<input type="password" name="password" placeholder="请输入密码" lay-verify="required" lay-reqtext="请输入密码" class="layui-input" autocomplete="off">
</div>
</div>
<div class="layui-form-item">
<input type="checkbox" name="remember" title="记住登录状态" lay-skin="primary">
</div>
<div class="layui-form-item">
<button class="layui-btn layui-btn-fluid login-btn" lay-submit lay-filter="login">立即登录</button>
</div>
<div class="error-msg" id="errorMsg"></div>
</form>
</div>
<div class="login-footer">
<p>{{ .FooterText }}</p>
</div>
</div>
<script src="https://unpkg.com/layui@2.10.1/dist/layui.js"></script>
<script>
layui.use(['form', 'layer'], function(){
var form = layui.form;
var layer = layui.layer;
// 登录提交回调:向 /admin/login 发送请求,并依据 code===0 判断成功与否
form.on('submit(login)', function(data){
var loadIndex = layer.load(1, {
shade: [0.1, '#fff']
});
// 发送登录请求
fetch('/admin/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data.field)
})
.then(response => response.json())
.then(result => {
layer.close(loadIndex);
// 根据统一接口code === 0 表示成功
const isOk = result && result.code === 0;
if (isOk) {
layer.msg('登录成功', {
icon: 1,
time: 1500
}, function(){
const redirect = (result.data && result.data.redirect) || '/admin';
window.location.href = redirect;
});
} else {
const msg = (result && (result.msg || result.message)) || '登录失败,请检查用户名和密码';
document.getElementById('errorMsg').style.display = 'block';
document.getElementById('errorMsg').textContent = msg;
layer.msg(msg, {icon: 2});
}
})
.catch(error => {
layer.close(loadIndex);
console.error('登录错误:', error);
document.getElementById('errorMsg').style.display = 'block';
document.getElementById('errorMsg').textContent = '网络错误,请稍后重试';
layer.msg('网络错误,请稍后重试', {icon: 2});
});
return false; // 阻止表单跳转
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,422 @@
{{ define "login_types.html" }}
<section>
<h2>登录方式管理</h2>
<div class="layui-btn-container" style="margin:12px 0">
<button class="layui-btn" id="btnAddLoginType"><i class="layui-icon layui-icon-add-1"></i> 新增方式</button>
<button class="layui-btn layui-btn-normal" id="btnBatchEnableLoginTypes"><i class="layui-icon layui-icon-ok-circle"></i> 批量启用</button>
<button class="layui-btn layui-btn-warm" id="btnBatchDisableLoginTypes"><i class="layui-icon layui-icon-close-fill"></i> 批量禁用</button>
<button class="layui-btn layui-btn-danger" id="btnBatchDeleteLoginTypes"><i class="layui-icon layui-icon-delete"></i> 批量删除</button>
</div>
<div class="layui-card" style="margin-top:12px">
<div class="layui-card-header">筛选</div>
<div class="layui-card-body">
<form class="layui-form layui-form-pane" id="loginTypeFilterForm" lay-filter="loginTypeFilterForm">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">名称</label>
<div class="layui-input-inline">
<input type="text" name="keyword" placeholder="登录方式名称" autocomplete="off" class="layui-input" />
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">状态</label>
<div class="layui-input-inline">
<select name="status">
<option value="">全部</option>
<option value="1">启用</option>
<option value="0">禁用</option>
</select>
</div>
</div>
<div class="layui-inline">
<button type="button" class="layui-btn" id="btnSearchLoginTypes">查询</button>
<button type="button" class="layui-btn layui-btn-primary" id="btnResetLoginTypes">重置</button>
</div>
</div>
</form>
</div>
</div>
<div class="layui-card" style="margin-top:12px">
<div class="layui-card-header">登录方式列表</div>
<div class="layui-card-body">
<table id="loginTypesTable" lay-filter="loginTypesTableFilter"></table>
<script type="text/html" id="tpl-logintypes-ops">
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>
<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="del">删除</a>
</script>
</div>
</div>
<!-- 隐藏的表单弹层内容 -->
<div id="loginTypeFormModal" style="display:none;padding:16px">
<!-- 参考demo表单2样式添加layui-form-pane类实现方框风格 -->
<form class="layui-form layui-form-pane" id="loginTypeForm" lay-filter="loginTypeForm">
<input type="hidden" name="id" />
<div class="layui-form-item">
<label class="layui-form-label">名称</label>
<div class="layui-input-block">
<input type="text" name="name" required lay-verify="required" placeholder="请输入登录方式名称" autocomplete="off" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">状态</label>
<div class="layui-input-block">
<select name="status">
<option value="1">启用</option>
<option value="0">禁用</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">验证类型</label>
<div class="layui-input-block">
<input type="text" name="verify_types" placeholder="请输入验证类型,多个用逗号分隔" autocomplete="off" class="layui-input" />
</div>
</div>
<!-- 操作按钮移除:统一由 layer.open 的 btn 控制“提交/取消” -->
</form>
</div>
</section>
<script>
// 登录类型管理页面的JavaScript脚本
layui.use(['table', 'form', 'layer'], function(){
const table = layui.table;
const form = layui.form;
const layer = layui.layer;
const $ = layui.$;
// 表格实例
let tableIns;
// 初始化表格
const initTable = () => {
tableIns = table.render({
elem: '#loginTypesTable',
url: '/admin/api/login_types/list',
method: 'GET',
page: true,
limit: 20,
limits: [10, 20, 50, 100],
loading: true,
cols: [[
{type: 'checkbox'},
{field: 'id', title: 'ID', width: 80, sort: true},
{field: 'name', title: '名称'},
{field: 'status', title: '状态', width: 100, templet: function(d){
return d.status === 1 ? '<span class="layui-badge layui-bg-green">启用</span>' : '<span class="layui-badge">禁用</span>';
}},
{field: 'verify_types', title: '验证类型'},
{field: 'created_at', title: '创建时间', width: 180, templet: function(d){
return formatDateTime(d.created_at);
}},
{field: 'updated_at', title: '更新时间', width: 180, templet: function(d){
return formatDateTime(d.updated_at);
}},
{title: '操作', toolbar: '#tpl-logintypes-ops', width: 150, fixed: 'right'}
]],
parseData: function(res){
// 后端已返回正确格式,直接使用
return {
"code": res.code,
"msg": res.msg || '',
"count": res.data ? res.data.total : 0,
"data": res.data ? res.data.items : []
};
},
request: {
pageName: 'page',
limitName: 'page_size'
},
where: {}
});
};
// 格式化日期时间
const formatDateTime = (dateStr) => {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.getFullYear() + '-' +
String(date.getMonth() + 1).padStart(2, '0') + '-' +
String(date.getDate()).padStart(2, '0') + ' ' +
String(date.getHours()).padStart(2, '0') + ':' +
String(date.getMinutes()).padStart(2, '0') + ':' +
String(date.getSeconds()).padStart(2, '0');
};
// 重载表格数据
const reloadTable = (where = {}) => {
tableIns.reload({
where: where,
page: {
curr: 1
}
});
};
// 获取选中的行数据
const getCheckData = () => {
const checkStatus = table.checkStatus('loginTypesTable');
return checkStatus.data;
};
// 当前表单弹窗索引
let currentFormLayerIndex = null;
// 显示表单弹层(统一使用 layer.open 的按钮作为确认/取消)
const showFormModal = (title, data = {}) => {
// 重置表单
$('#loginTypeForm')[0].reset();
// 填充表单数据
if (data.id) {
$('input[name="id"]').val(data.id);
$('input[name="name"]').val(data.name);
$('select[name="status"]').val(data.status);
$('input[name="verify_types"]').val(data.verify_types);
}
// 刷新表单渲染
form.render();
// 显示弹层并保存索引
currentFormLayerIndex = layer.open({
type: 1,
title: title,
content: $('#loginTypeFormModal'),
area: ['500px', '300px'],
btn: ['提交', '取消'],
btnAlign: 'c',
yes: () => {
// 点击“提交”时执行提交
doLoginTypeSubmit();
},
btn2: (index) => {
// 点击“取消”时关闭弹层
layer.close(index);
},
closeBtn: 1
});
};
// 提交表单(通过 layer.open 的“提交”按钮触发)
const doLoginTypeSubmit = () => {
// 读取表单数据并校验
const idVal = $('input[name="id"]').val();
const isEdit = idVal && idVal !== '';
const name = $('input[name="name"]').val().trim();
const status = parseInt($('select[name="status"]').val() || '0');
const verifyTypes = $('input[name="verify_types"]').val().trim();
if (!name) {
layer.msg('请输入登录方式名称', { icon: 2 });
return;
}
const url = isEdit ? '/admin/api/login_types/update' : '/admin/api/login_types/create';
const requestData = {
name: name,
status: status,
verify_types: verifyTypes
};
if (isEdit) requestData.id = parseInt(idVal);
$.ajax({
url: url,
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(requestData),
success: function(res) {
if (res.code === 0) {
layer.msg(isEdit ? '更新成功' : '创建成功', { icon: 1, time: 1000 });
layer.close(currentFormLayerIndex);
reloadTable();
} else {
// 失败时对提示信息做截断,避免过长
const raw = res.msg || '操作失败';
const shortMsg = raw.length > 100 ? (raw.slice(0, 100) + '...') : raw;
layer.msg(shortMsg, { icon: 2 });
}
},
// 优先展示后端返回的业务错误信息,避免统一显示“网络错误”
error: (xhr) => {
// 失败时对提示信息做截断,避免过长
const raw = (xhr.responseJSON && xhr.responseJSON.msg) ? xhr.responseJSON.msg : '网络错误,请重试';
const shortMsg = raw.length > 100 ? (raw.slice(0, 100) + '...') : raw;
layer.msg(shortMsg, { icon: 2 });
}
});
};
// 删除单个记录
// 说明删除前二次确认后端返回400/500也能显示具体错误信息
const deleteItem = (id) => {
layer.confirm('确定要删除这条记录吗?', {icon: 3, title: '提示'}, function(index){
$.ajax({
url: '/admin/api/login_types/delete',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({id: id}),
success: function(res) {
if (res.code === 0) {
layer.msg('删除成功', { icon: 1, time: 3000 });
reloadTable();
} else {
// 删除失败:使用折行展示错误信息,便于阅读(不再截断)
const raw = res.msg || '删除失败';
const safe = String(raw)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
// 将常见分隔符替换为换行,结合 white-space: pre-wrap 生效
const content = `<div style="white-space:pre-wrap;word-break:break-word;">${safe.replace(/[,;]/g, '\n')}</div>`;
layer.msg(content, { icon: 2 });
}
},
// 解析后端JSON错误响应展示msg内容支持折行
error: (xhr) => {
const raw = (xhr.responseJSON && xhr.responseJSON.msg) ? xhr.responseJSON.msg : '网络错误,请重试';
const safe = String(raw)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const content = `<div style="white-space:pre-wrap;word-break:break-word;">${safe.replace(/[,;]/g, '\n')}</div>`;
layer.msg(content, { icon: 2 });
}
});
layer.close(index);
});
};
// 批量操作
// 参数operation 用于提示文案url 为接口地址confirmMsg 为确认提示语
const batchOperation = (operation, url, confirmMsg) => {
const checkData = getCheckData();
if (checkData.length === 0) {
layer.msg('请选择要操作的数据', { icon: 2 });
return;
}
const ids = checkData.map(item => item.id);
layer.confirm(confirmMsg, {icon: 3, title: '提示'}, function(index){
$.ajax({
url: url,
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ids: ids}),
// 统一成功/失败提示移除残留的diff标记
success: function(res) {
if (res.code === 0) {
layer.msg(operation + '成功', { icon: 1 });
reloadTable();
} else {
// 批量失败:使用折行展示长信息(例如占用明细),便于阅读
const raw = res.msg || operation + '失败';
const safe = String(raw)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const content = `<div style="white-space:pre-wrap;word-break:break-word;">${safe.replace(/[,;]/g, '\n')}</div>`;
layer.msg(content, { icon: 2 });
}
},
// 出错时同样尝试展示后端返回的msg支持折行
error: (xhr) => {
const raw = (xhr.responseJSON && xhr.responseJSON.msg) ? xhr.responseJSON.msg : '网络错误,请重试';
const safe = String(raw)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const content = `<div style="white-space:pre-wrap;word-break:break-word;">${safe.replace(/[,;]/g, '\n')}</div>`;
layer.msg(content, { icon: 2 });
}
});
layer.close(index);
});
};
// 事件绑定
// 新增按钮
$('#btnAddLoginType').on('click', function(){
showFormModal('新增登录方式');
});
// 批量启用按钮
$('#btnBatchEnableLoginTypes').on('click', function(){
batchOperation('批量启用', '/admin/api/login_types/batch_enable', '确定要启用选中的登录方式吗?');
});
// 批量禁用按钮
$('#btnBatchDisableLoginTypes').on('click', function(){
batchOperation('批量禁用', '/admin/api/login_types/batch_disable', '确定要禁用选中的登录方式吗?');
});
// 批量删除按钮
$('#btnBatchDeleteLoginTypes').on('click', function(){
batchOperation('批量删除', '/admin/api/login_types/batch_delete', '确定要删除选中的登录方式吗?删除后不可恢复!');
});
// 查询按钮
$('#btnSearchLoginTypes').on('click', function(){
const formData = form.val('loginTypeFilterForm');
const where = {};
if (formData.keyword && formData.keyword.trim() !== '') {
where.keyword = formData.keyword.trim();
}
if (formData.status && formData.status !== '') {
where.status = formData.status;
}
reloadTable(where);
});
// 重置按钮
$('#btnResetLoginTypes').on('click', function(){
$('#loginTypeFilterForm')[0].reset();
form.render();
reloadTable();
});
// Layui表单提交事件
// 删除 Layui 表单提交监听(由 layer.open 的“提交”按钮统一触发)
// form.on('submit(loginTypeSubmit)', function(data){
// submitForm();
// return false; // 阻止表单跳转
// });
// 删除表单取消按钮事件(由 layer.open 的“取消”按钮统一处理)
// $('#btnCancelLoginType').on('click', function(){
// layer.close(currentFormLayerIndex);
// });
// 表格工具栏事件
table.on('tool(loginTypesTableFilter)', function(obj){
const data = obj.data;
const layEvent = obj.event;
if (layEvent === 'edit') {
showFormModal('编辑登录方式', data);
} else if (layEvent === 'del') {
deleteItem(data.id);
}
});
// 初始化页面
initTable();
});
</script>
{{ end }}

View File

@@ -0,0 +1,277 @@
{{ define "settings.html" }}
<section>
<h2>系统设置</h2>
<!-- 基本信息设置 -->
<div class="layui-card" style="margin-top: 16px;">
<div class="layui-card-header">基本信息设置</div>
<div class="layui-card-body">
<form class="layui-form" id="basicForm">
<div class="layui-form-item">
<label class="layui-form-label">站点标题</label>
<div class="layui-input-block">
<input type="text" name="site_title" lay-verify="required" placeholder="请输入站点标题" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">关键词</label>
<div class="layui-input-block">
<input type="text" name="site_keywords" placeholder="请输入站点关键词,多个关键词用逗号分隔" class="layui-input" />
</div>
</div>
<div class="layui-form-item layui-form-text">
<label class="layui-form-label">站点描述</label>
<div class="layui-input-block">
<textarea name="site_description" placeholder="请输入站点描述" class="layui-textarea"></textarea>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">站点Logo</label>
<div class="layui-input-block">
<input type="text" name="site_logo" placeholder="/assets/logo.svg" class="layui-input" />
</div>
</div>
</form>
</div>
</div>
<!-- 系统配置设置 -->
<div class="layui-card" style="margin-top: 16px;">
<div class="layui-card-header">系统配置</div>
<div class="layui-card-body">
<form class="layui-form" id="systemForm">
<div class="layui-form-item">
<label class="layui-form-label">关闭系统</label>
<div class="layui-input-block">
<div style="display: flex; align-items: center; justify-content: flex-start; gap: 10px;">
<input type="checkbox" name="maintenance_mode" lay-skin="switch" lay-text="关闭系统|开启系统" title="关闭系统|开启系统">
</div>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">默认角色</label>
<div class="layui-input-block">
<select name="default_user_role">
<option value="0">管理员</option>
<option value="1">普通用户</option>
</select>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">会话超时</label>
<div class="layui-input-block">
<div style="display: flex; align-items: center; gap: 10px;">
<input type="number" name="session_timeout" placeholder="3600" min="300" max="86400" class="layui-input" style="width: 120px;" />
<span class="layui-form-mid">300-86400秒</span>
</div>
</div>
</div>
</form>
</div>
</div>
<!-- 页脚与备案信息 -->
<div class="layui-card" style="margin-top: 16px;">
<div class="layui-card-header">页脚与备案</div>
<div class="layui-card-body">
<form class="layui-form" id="footerForm">
<div class="layui-form-item layui-form-text">
<label class="layui-form-label">页脚文本</label>
<div class="layui-input-block">
<textarea name="footer_text" placeholder="© 2025 凌动技术 保留所有权利" class="layui-textarea"></textarea>
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">ICP备案</label>
<div class="layui-input-block">
<input type="text" name="icp_record" placeholder="京ICP备12345678号" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">备案链接</label>
<div class="layui-input-block">
<input type="url" name="icp_record_link" placeholder="https://beian.miit.gov.cn" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">公安备案</label>
<div class="layui-input-block">
<input type="text" name="psb_record" placeholder="京公网安备11010802012345号" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">备案链接</label>
<div class="layui-input-block">
<input type="url" name="psb_record_link" placeholder="http://www.beian.gov.cn/portal/registerSystemInfo" class="layui-input" />
</div>
</div>
</form>
</div>
</div>
<!-- 操作按钮 -->
<div class="layui-form-item" style="margin-top: 24px;">
<div class="layui-input-block">
<button type="button" class="layui-btn" id="saveAllBtn" lay-submit lay-filter="saveAll">保存所有设置</button>
<button type="button" class="layui-btn layui-btn-primary" id="resetBtn">重置</button>
</div>
</div>
</section>
<script>
layui.use(['jquery', 'form', 'layer'], function() {
const { $, form, layer } = layui;
// 缓存上次加载的设置值,用于“重置”恢复
let originalSettings = {};
/**
* 加载后台所有设置并回填到三个表单
* - 从 /admin/api/settings 获取 name:value 映射
* - 处理开关型字段maintenance_mode
* - 渲染 layui 组件
*/
const loadSettings = async () => {
try {
const res = await fetch('/admin/api/settings', {
method: 'GET',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await res.json();
if (data.code !== 0) {
layer.msg(data.msg || '加载设置失败', { icon: 2 });
return;
}
originalSettings = data.data || {};
fillForms(originalSettings);
} catch (err) {
console.error('获取设置失败:', err);
layer.msg('网络错误,无法加载设置', { icon: 2 });
}
};
/**
* 将 settings 数据回填到各表单控件
* - 文本/文本域/下拉:直接赋值
* - 开关:根据 "1"/"0" 置为选中/未选中
*/
const fillForms = (settings = {}) => {
// 基本信息
$('[name="site_title"]').val(settings.site_title || '');
$('[name="site_keywords"]').val(settings.site_keywords || '');
$('[name="site_description"]').val(settings.site_description || '');
$('[name="site_logo"]').val(settings.site_logo || '');
// 系统配置
const maintenanceChecked = (settings.maintenance_mode || '0') === '1';
$('[name="maintenance_mode"]').prop('checked', maintenanceChecked);
$('[name="default_user_role"]').val(settings.default_user_role || '1');
$('[name="session_timeout"]').val(settings.session_timeout || '3600');
// 页脚与备案
$('[name="footer_text"]').val(settings.footer_text || '');
$('[name="icp_record"]').val(settings.icp_record || '');
$('[name="icp_record_link"]').val(settings.icp_record_link || '');
$('[name="psb_record"]').val(settings.psb_record || '');
$('[name="psb_record_link"]').val(settings.psb_record_link || '');
// 渲染 layui 组件
form.render();
};
/**
* 收集某个表单下所有可用控件的值
* - 统一将 checkbox 转为 "1"/"0"
* - 其他控件转为字符串,避免后端类型不一致
*/
const collectForm = (selector) => {
const obj = {};
const $form = $(selector);
$form.find('input, textarea, select').each(function() {
const $el = $(this);
const name = $el.attr('name');
if (!name) return; // 无 name 不纳入
const type = ($el.attr('type') || '').toLowerCase();
let value = '';
if (type === 'checkbox') {
value = $el.prop('checked') ? '1' : '0';
} else {
value = ($el.val() ?? '').toString();
}
obj[name] = value;
});
return obj;
};
/**
* 汇总三个表单的字段为一个扁平对象
*/
const collectAllSettings = () => {
return {
...collectForm('#basicForm'),
...collectForm('#systemForm'),
...collectForm('#footerForm'),
};
};
/**
* 处理“保存所有设置”点击
* - 二次确认后提交
* - 显示加载中,防重复提交
* - 成功后提示并刷新缓存的 originalSettings
*/
const handleSaveAll = () => {
const payload = collectAllSettings();
layer.confirm('确认保存所有设置?', { icon: 3, title: '提示' }, (idx) => {
layer.close(idx);
const btn = $('#saveAllBtn');
btn.prop('disabled', true).addClass('layui-btn-disabled');
const loadIdx = layer.load(2, { content: '正在保存...' });
fetch('/admin/api/settings/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(payload)
})
.then(resp => resp.json())
.then(res => {
if (res.code === 0) {
layer.msg(res.msg || '保存成功', { icon: 1, time: 1000 });
originalSettings = { ...payload };
} else {
layer.msg(res.msg || '保存失败', { icon: 2 });
}
})
.catch(err => {
console.error('保存设置失败:', err);
layer.msg('网络错误,保存失败', { icon: 2 });
})
.finally(() => {
layer.close(loadIdx);
btn.prop('disabled', false).removeClass('layui-btn-disabled');
});
});
};
/**
* 处理“重置”点击
* - 恢复为上次加载的 originalSettings
*/
const handleReset = () => {
fillForms(originalSettings);
layer.msg('已恢复到上次加载的值', { icon: 1, time: 800 });
};
// 事件绑定
$('#saveAllBtn').off('click').on('click', handleSaveAll);
$('#resetBtn').off('click').on('click', handleReset);
// 初始化:加载设置
loadSettings();
});
</script>
{{ end }}

View File

@@ -0,0 +1,256 @@
{{ define "user.html" }}
<div class="layui-card">
<div class="layui-card-header">个人资料</div>
<div class="layui-card-body">
<form class="layui-form" id="accountForm" lay-filter="accountForm" onsubmit="return false">
<!-- 按照要求纵向排序ID、角色、用户名、旧密码、新密码、确认新密码 -->
<div class="layui-form-item">
<label class="layui-form-label">ID</label>
<div class="layui-input-block">
<input type="text" name="id" disabled readonly class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">角色</label>
<div class="layui-input-block">
<!-- 角色禁用与只读,仅作展示用途,显示中文标签“管理员/普通成员” -->
<input type="text" name="role" disabled readonly class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">用户名</label>
<div class="layui-input-block">
<input type="text" name="username" placeholder="请输入用户名(不修改可留空或保持不变)" autocomplete="off" class="layui-input" />
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">旧密码</label>
<div class="layui-input-block">
<!-- 不修改密码时可留空 -->
<input type="password" name="old_password" placeholder="不修改可留空" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">新密码</label>
<div class="layui-input-block">
<!-- 不修改密码时可留空 -->
<input type="password" name="new_password" placeholder="不修改可留空至少6位" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">确认密码</label>
<div class="layui-input-block">
<!-- 不修改密码时可留空 -->
<input type="password" name="confirm_password" placeholder="不修改可留空" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block">
<button class="layui-btn" lay-submit lay-filter="submitAccount">保存更改</button>
<!-- 将原先 type="reset" 改为自定义按钮,避免浏览器重置成初始空值 -->
<button type="button" id="btnReset" class="layui-btn layui-btn-primary">重置</button>
</div>
</div>
</form>
</div>
</div>
<script>
// 使用自执行函数创建局部作用域,避免与其他页面脚本发生全局命名冲突
(() => {
// 工具方法:将数值角色转为中文标签
// 0 => 管理员1 => 普通成员
const roleToText = (role) => {
// 将可能的字符串数值转为数字
const r = typeof role === 'string' ? parseInt(role, 10) : role
return r === 0 ? '管理员' : '普通成员'
}
// 如果未加载 layui则按需加载兼容用户直接访问片段页 /admin/user
// 说明:当 window.layui 不存在时,动态引入 Layui 的 CSS 和 JS加载完成后再执行页面逻辑
const ensureLayui = () => new Promise((resolve) => {
if (window.layui) return resolve(window.layui)
const css = document.createElement('link')
css.rel = 'stylesheet'
css.href = 'https://unpkg.com/layui@2.10.1/dist/css/layui.css'
document.head.appendChild(css)
const script = document.createElement('script')
script.src = 'https://unpkg.com/layui@2.10.1/dist/layui.js'
script.onload = () => resolve(window.layui)
document.head.appendChild(script)
})
// 在确保 Layui 可用后再执行页面逻辑
ensureLayui().then(() => {
layui.use(['form', 'layer'], () => {
const form = layui.form
const layer = layui.layer
// 记录初始用户名,用于判断是否需要更新
let initialUsername = ''
// 缓存最近一次加载到表单中的资料,用于“重置”恢复
let lastProfile = null
// 加载个人资料填充ID/用户名/角色(角色显示中文标签并禁用)
// 返回:无;副作用:设置 initialUsername、lastProfile 与表单值
const loadProfile = async () => {
try {
const res = await fetch('/admin/api/user/profile')
const data = await res.json()
const ok = (data.success === true) || (data.code === 0)
if (!ok) throw new Error(data.message || data.msg || '加载失败')
const payload = data.data || {}
initialUsername = payload.username || ''
// 将角色转换为中文展示,并缓存为最近一次加载的“默认值”
const display = { ...payload, role: roleToText(payload.role) }
lastProfile = display
form.val('accountForm', display)
} catch (e) {
layer.msg(e.message || '加载个人资料失败', { icon: 2 })
}
}
// 校验密码表单:当任一密码字段填写时,要求三个字段均填写且有效
// 返回:{ ok: boolean, msg?: string }
const validatePassword = (fields) => {
const oldPwd = (fields.old_password || '').trim()
const newPwd = (fields.new_password || '').trim()
const confirmPwd = (fields.confirm_password || '').trim()
const anyFilled = !!(oldPwd || newPwd || confirmPwd)
if (!anyFilled) return { ok: true }
if (!oldPwd || !newPwd || !confirmPwd) return { ok: false, msg: '请完整填写旧密码/新密码/确认新密码' }
if (newPwd.length < 6) return { ok: false, msg: '新密码长度不能少于6位' }
if (newPwd !== confirmPwd) return { ok: false, msg: '两次输入的新密码不一致' }
if (oldPwd === newPwd) return { ok: false, msg: '新密码不能与旧密码相同' }
return { ok: true }
}
// 更新用户名:传输 username 与 old_password当仅修改用户名时必须提供当前密码同时修改密码时沿用同一 old_password
// 返回Promise<void>
const updateUsername = async (username, oldPassword) => {
const payload = { username }
if (oldPassword) payload.old_password = oldPassword
const res = await fetch('/admin/api/user/profile/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const data = await res.json()
const ok = (data.success === true) || (data.code === 0)
if (!ok) throw new Error(data.message || data.msg || '保存资料失败')
}
// 更新密码:仅传输旧/新/确认三个字段
// 返回Promise<any> 后端响应数据,用于可能的重定向处理
const updatePassword = async (fields) => {
const payload = {
old_password: fields.old_password,
new_password: fields.new_password,
confirm_password: fields.confirm_password
}
const res = await fetch('/admin/api/user/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const data = await res.json()
const ok = (data.success === true) || (data.code === 0)
if (!ok) throw new Error(data.message || data.msg || '修改密码失败')
return data
}
// 提交综合更新:
// 规则:
// - 用户名:仅当与 initialUsername 不同且非空时更新
// - 密码:当任一密码字段填写时,要求完整校验并更新;若均未填则不更新
// - 若两者均无改动,则提示“未修改任何内容”
form.on('submit(submitAccount)', async (obj) => {
const fields = obj.field
const desiredUsername = (fields.username || '').trim()
const needUpdateUsername = desiredUsername && desiredUsername !== initialUsername
// 判定密码相关输入:
// - wantChangePassword输入了新密码或确认密码视为尝试修改密码将要求三个字段都填写
// - onlyOldProvided仅输入了旧密码用于支持“仅修改用户名需要当前密码”的场景
const hasOld = !!(fields.old_password && fields.old_password.trim())
const hasNewOrConfirm = !!((fields.new_password && fields.new_password.trim()) || (fields.confirm_password && fields.confirm_password.trim()))
const wantChangePassword = hasNewOrConfirm
const onlyOldProvided = hasOld && !hasNewOrConfirm
if (!needUpdateUsername && !wantChangePassword) {
layer.msg('未修改任何内容', { icon: 0 })
return false
}
// 修改密码场景:需进行严格校验(旧/新/确认均必填)
if (wantChangePassword) {
const pwdCheck = validatePassword(fields)
if (!pwdCheck.ok) {
layer.msg(pwdCheck.msg, { icon: 2 })
return false
}
}
// 仅修改用户名:要求输入当前密码
if (needUpdateUsername && !wantChangePassword && !hasOld) {
layer.msg('修改用户名需要输入当前密码', { icon: 2 })
return false
}
try {
// 始终先更新用户名,再更新密码(避免改密后跳转导致无法继续)
if (needUpdateUsername) {
await updateUsername(desiredUsername, hasOld ? fields.old_password : '')
initialUsername = desiredUsername
}
if (wantChangePassword) {
const pwdResp = await updatePassword(fields)
// 修改密码后通常需要重新登录,优先使用后端返回的 redirect否则默认登录页
const redirect = pwdResp && pwdResp.data && pwdResp.data.redirect ? pwdResp.data.redirect : '/admin/login'
layer.msg('密码修改成功,即将跳转到登录页', { icon: 1, time: 1200 }, () => {
window.location.href = redirect
})
} else {
// 未修改密码,仅修改资料
await loadProfile()
layer.msg('保存成功', { icon: 1 })
}
} catch (e) {
layer.msg(e.message || '保存失败', { icon: 2 })
}
return false
})
// 绑定“重置”按钮:将表单恢复为最近一次加载到表单中的资料
// 逻辑:
// - 如有 lastProfile直接回填
// - 回填时同时清空三个密码字段;
// - 如暂无缓存(极小概率),则重新请求资料
const bindReset = () => {
const btn = document.getElementById('btnReset')
if (!btn) return
btn.addEventListener('click', () => {
if (lastProfile) {
form.val('accountForm', { ...lastProfile, old_password: '', new_password: '', confirm_password: '' })
layer.msg('已恢复为当前资料', { icon: 1 })
} else {
loadProfile()
}
})
}
// 初始化加载
bindReset()
loadProfile()
})
})
})()
</script>
{{ end }}

366
web/template/index.html Normal file
View File

@@ -0,0 +1,366 @@
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<title>{{.SystemName}} - 生活就像愤怒的小鸟,失败后总有几只猪在笑。</title>
<!-- 站 点 协 议 -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<meta http-equiv="content-language" content="zh-cn">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="mobile-web-app-capable" content="yes">
<meta name="format-detection" content="telephone=no">
<!-- 站 点 图 标 -->
<link href='/favicon.ico' rel='icon' type='image/x-icon'>
<link href="/favicon.ico" rel="shortcut icon">
<link href="/favicon.ico" rel="bookmark">
<!-- 样 式 文 件 -->
<link rel="stylesheet" href="//lib.baomitu.com/layui/2.8.17/css/layui.css"/>
<style>
html, body {
width: 100%;
height: 100%;
overflow: hidden;
margin: 0;
padding: 0;
font-family: 'Microsoft YaHei', Arial, sans-serif;
}
body {
background-color: #000000 !important;
}
.layui-container {
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
}
.body-background {
width: 420px;
min-height: 350px;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 10;
}
.logo-title {
text-align: center;
letter-spacing: 3px;
padding: 0 0 0 0;
margin-bottom: 5px;
}
.logo-title h1 {
color: #2550dd;
font-size: 28px;
font-weight: 600;
margin: 0;
text-shadow: 0 0 20px rgba(0, 212, 255, 0.5);
animation: glow 2s ease-in-out infinite alternate;
}
@keyframes glow {
from {
text-shadow: 0 0 20px rgba(0, 212, 255, 0.5);
}
to {
text-shadow: 0 0 30px rgba(0, 212, 255, 0.8), 0 0 40px rgba(0, 212, 255, 0.6);
}
}
.box-form {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(240, 248, 255, 0.9));
border: 2px solid rgba(0, 212, 255, 0.3);
border-radius: 15px;
padding: 30px 25px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(255, 255, 255, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
position: relative;
overflow: hidden;
}
.box-form::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.1), transparent);
animation: shimmer 3s infinite;
}
@keyframes shimmer {
0% { left: -100%; }
100% { left: 100%; }
}
.box-form .layui-form-item {
margin-bottom: 20px;
position: relative;
}
.warning-text {
font-size: 24px;
color: #ff4757;
font-weight: 600;
text-shadow: 0 2px 4px rgba(255, 71, 87, 0.3);
margin: 15px 0;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.info-text {
color: #3742fa;
font-size: 16px;
font-weight: 500;
margin: 15px 0;
text-shadow: 0 1px 2px rgba(55, 66, 250, 0.2);
}
.body_box {
text-align: center;
}
.body_footer {
padding-top: 15px;
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.body_beian {
padding-top: 8px;
}
.body_beian a {
color: rgba(0, 212, 255, 0.8);
text-decoration: none;
font-size: 13px;
transition: all 0.3s ease;
}
.body_beian a:hover {
color: #00d4ff;
text-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
}
#canvas {
position: absolute;
top: 0;
left: 0;
z-index: 1;
}
hr {
border: none;
height: 2px;
background: linear-gradient(90deg, transparent, #00d4ff, transparent);
margin: 20px 0;
border-radius: 1px;
}
</style>
</head>
<body>
<!-- 代 码 结 构 -->
<div class="layui-container">
<canvas id="canvas"></canvas>
<div class="body-background body_box">
<div class="layui-form box-form body_box">
<div class="layui-form-item logo-title">
<h1><strong>系统提醒</strong></h1>
</div>
<hr>
<div class="layui-form-item">
<div class="warning-text">🚫 未授权,拒绝访问</div>
</div>
<div class="layui-form-item">
<div class="info-text">💬 如有问题,请联系网站管理员</div>
</div>
</div>
<div class="body_footer">{{.FooterText}}</div>
{{if or .ICPRecord .PSBRecord}}<div class="body_beian">{{if .ICPRecord}}<a href="{{.ICPRecordLink}}" target="_blank">{{.ICPRecord}}</a>{{end}}{{if and .ICPRecord .PSBRecord}} {{end}}{{if .PSBRecord}}<a href="{{.PSBRecordLink}}" target="_blank">{{.PSBRecord}}</a>{{end}}</div>{{end}}
</div>
</div>
<!-- 资 源 引 入 -->
<script src="//lib.baomitu.com/jquery/3.6.4/jquery.min.js" type="text/javascript"></script>
<script>
// 获取canvas元素和绘图上下文
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// 设置canvas尺寸为全屏
const resizeCanvas = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// 粒子类
class Particle {
constructor() {
this.reset();
}
// 重置粒子位置和属性
reset() {
this.x = Math.random() * canvas.width;
this.y = Math.random() * canvas.height;
this.vx = (Math.random() - 0.5) * 2;
this.vy = (Math.random() - 0.5) * 2;
this.size = Math.random() * 3 + 1;
this.opacity = Math.random() * 0.8 + 0.2;
this.color = this.getRandomColor();
}
// 获取随机颜色
getRandomColor() {
const colors = [
'#00FF00', '#0080FF', '#FF0080', '#FFFF00',
'#FF8000', '#8000FF', '#00FFFF', '#FF4000'
];
return colors[Math.floor(Math.random() * colors.length)];
}
// 更新粒子位置
update() {
this.x += this.vx;
this.y += this.vy;
// 边界检测,粒子超出边界时重置
if (this.x < 0 || this.x > canvas.width ||
this.y < 0 || this.y > canvas.height) {
this.reset();
}
// 随机改变透明度
this.opacity += (Math.random() - 0.5) * 0.02;
this.opacity = Math.max(0.1, Math.min(1, this.opacity));
}
// 绘制粒子
draw() {
ctx.save();
ctx.globalAlpha = this.opacity;
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
// 创建粒子数组
const particles = [];
const particleCount = 150;
// 初始化粒子
const initParticles = () => {
for (let i = 0; i < particleCount; i++) {
particles.push(new Particle());
}
};
// 绘制连线
const drawConnections = () => {
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const dx = particles[i].x - particles[j].x;
const dy = particles[i].y - particles[j].y;
const distance = Math.sqrt(dx * dx + dy * dy);
// 如果距离小于100像素绘制连线
if (distance < 100) {
ctx.save();
ctx.globalAlpha = (100 - distance) / 100 * 0.3;
ctx.strokeStyle = '#00FF00';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(particles[i].x, particles[i].y);
ctx.lineTo(particles[j].x, particles[j].y);
ctx.stroke();
ctx.restore();
}
}
}
};
// 动画循环
const animate = () => {
// 清除画布,使用半透明黑色创建拖尾效果
ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 更新和绘制所有粒子
particles.forEach(particle => {
particle.update();
particle.draw();
});
// 绘制粒子间的连线
drawConnections();
requestAnimationFrame(animate);
};
// 鼠标交互效果
const addMouseInteraction = () => {
let mouseX = 0;
let mouseY = 0;
canvas.addEventListener('mousemove', (e) => {
mouseX = e.clientX;
mouseY = e.clientY;
// 鼠标附近的粒子会被吸引
particles.forEach(particle => {
const dx = mouseX - particle.x;
const dy = mouseY - particle.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 150) {
particle.vx += dx * 0.0001;
particle.vy += dy * 0.0001;
}
});
});
// 点击时添加新粒子
canvas.addEventListener('click', (e) => {
for (let i = 0; i < 5; i++) {
const newParticle = new Particle();
newParticle.x = e.clientX + (Math.random() - 0.5) * 20;
newParticle.y = e.clientY + (Math.random() - 0.5) * 20;
particles.push(newParticle);
}
// 限制粒子数量
if (particles.length > particleCount + 50) {
particles.splice(0, 5);
}
});
};
// 启动粒子系统
initParticles();
addMouseInteraction();
animate();
</script>
</body>
</html>