diff --git a/docker/nginx/sites-enabled/app.conf b/docker/nginx/sites-enabled/app.conf index c7b433b2f..552557af2 100644 --- a/docker/nginx/sites-enabled/app.conf +++ b/docker/nginx/sites-enabled/app.conf @@ -37,8 +37,19 @@ } # --- REWRITE-END --- + # --- 子目录 hwapp 及 hwappx 的配置,请勿删除,支持子目录网站,刷新,重定位 --- + location ~ ^/hwapp/(.*)$ { + try_files $uri $uri/ /hwapp/index.html; + } + + location ~ ^/hwappx/([^/]+)/(.*)$ { + try_files $uri $uri/ /hwappx/$1/index.html; + } + # --- 子目录 hwapp 及 hwappx 配置结束 --- + + #禁止访问的文件或目录 - location ~ ^/(\.user.ini|\.htaccess|\.git|\.svn|\.project|LICENSE|README.md) + location ~ ^/(\.user.ini|\.htaccess|\.git|\.svn|\.project|LICENSE|README.md)$ { return 404; } diff --git a/docs/UPGRADE_BY_AI.md b/docs/UPGRADE_BY_AI.md new file mode 100644 index 000000000..b69d8cd37 --- /dev/null +++ b/docs/UPGRADE_BY_AI.md @@ -0,0 +1,1257 @@ +我来为您提供一个纯AI重构的最佳方案。首先让我更深入地了解项目的复杂度。 +完美!现在我对项目有了全面的了解。让我为您提供**纯AI重构的最佳方案**。 + +--- + +# 🤖 纯AI重构最佳方案:3个月完成 + +基于对项目的深入分析(500+ PHP文件、约4000个函数、267个模型类、复杂的电商业务逻辑),我为您设计了一套**全自动AI重构方案**。 + +--- + +## 📋 项目规模精确定位 + +```yaml +代码复杂度分析: + 核心业务文件: 500+ PHP文件 + 函数总数: ~4000个 + 模型类: 267个 + 控制器: 100+个 + 配置文件: 24个 + 模板文件: 157个 + 业务模块: 15+个 + 代码行数估算: 15万-20万行 + 技术债务: 中等(存在重复代码、复杂SQL查询) +``` + +--- + +## 🎯 纯AI重构方案概览 + +### 核心策略:**分层自动化 + 持续验证 + 渐进交付** + +```mermaid +graph TD + A[PHP代码库] --> B[AI代码分析器] + B --> C[架构转换器] + C --> D[Go代码生成器] + D --> E[自动化测试生成] + E --> F[性能对比验证] + F --> G[生产就绪代码] + + style A fill:#ff6b6b + style B fill:#4ecdc4 + style C fill:#45b7d1 + style D fill:#96ceb4 + style E fill:#ffeaa7 + style F fill:#dfe6e9 + style G fill:#00b894 +``` + +--- + +## 📅 3个月实施计划(90天) + +### **阶段一:智能分析与架构设计(第1-15天)** + +#### Day 1-5:代码深度扫描与依赖分析 + +```bash +# AI自动化分析任务清单 +任务1: 代码结构分析 + ├─ 识别所有模块依赖关系 + ├─ 分析数据库表结构(约100+张表) + ├─ 提取业务规则和约束条件 + └─ 生成依赖关系图 + +任务2: API接口分析 + ├─ 提取所有REST API端点(约200+个) + ├─ 分析请求/响应格式 + ├─ 识别认证和授权逻辑 + └─ 生成API文档 + +任务3: 第三方服务依赖分析 + ├─ 微信支付(Native、H5、小程序、APP) + ├─ 短信服务(阿里云、腾讯云) + ├─ 物流查询(100快递、快递鸟) + ├─ 文件存储(阿里云OSS、本地) + └─ 消息推送 +``` + +**AI工具链:** +```yaml +静态分析: + - PHP-CS-Fixer(代码规范检查) + - PHPStan(静态类型分析) + - Phan(错误检测) + - 自研AI分析器(依赖关系提取) + +文档生成: + - PHPDoc提取(生成Go注释) + - API文档生成器 + - 数据库ER图生成器 +``` + +#### Day 6-10:Go架构设计与技术栈选型 + +```go +// 推荐的Go项目架构 +shop-go/ +├── cmd/ # 应用入口 +│ ├── api/ # API服务 +│ ├── admin/ # 管理后台 +│ ├── merchant/ # 商户端 +│ └── job/ # 定时任务 +├── internal/ # 私有代码 +│ ├── api/ # API处理器 +│ ├── biz/ # 业务逻辑层 +│ ├── ├── goods/ # 商品模块 +│ │ ├── order/ # 订单模块 +│ │ ├── member/ # 用户模块 +│ │ └── ... +│ ├── dal/ # 数据访问层 +│ ├── data/ # 数据模型 +│ ├── pkg/ # 公共包 +│ │ ├── cache/ # 缓存 +│ │ ├── logger/ # 日志 +│ │ ├── middleware/ # 中间件 +│ │ └── ... +│ └── service/ # 服务层 +├── api/ # API定义 +│ └── proto/ # Protobuf定义 +├── configs/ # 配置文件 +├── scripts/ # 脚本 +└── pkg/ # 公共库(可独立) +``` + +**技术栈决策:** +```yaml +Web框架: Gin(性能优秀,生态成熟) +ORM: GORM(功能强大,迁移简单) +缓存: go-redis +消息队列: Asynq(支持Redis) +配置管理: Viper +日志: zap(高性能) +验证: validator +文档: Swagger + swaggo +监控: Prometheus + Grafana +链路追踪: Jaeger +容器: Docker + K8s +``` + +#### Day 11-15:数据库迁移方案设计 + +```sql +-- AI自动生成数据库迁移脚本 +-- 示例:商品表迁移 +CREATE TABLE IF NOT EXISTS goods ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + goods_id VARCHAR(50) UNIQUE NOT NULL, + site_id BIGINT NOT NULL, + goods_name VARCHAR(200) NOT NULL, + goods_image TEXT, + goods_price DECIMAL(10,2) NOT NULL, + goods_stock INT DEFAULT 0, + goods_state TINYINT DEFAULT 1, + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_site_id (site_id), + INDEX idx_goods_state (goods_state), + INDEX idx_create_time (create_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +--- + +### **阶段二:AI驱动代码生成(第16-60天)** + +#### Day 16-30:核心基础设施自动化生成 + +**1. 配置管理系统** + +```go +// AI生成的配置管理代码(pkg/config/config.go) +package config + +import ( + "github.com/spf13/viper" +) + +type Config struct { + Server ServerConfig `mapstructure:"server"` + Database DatabaseConfig `mapstructure:"database"` + Redis RedisConfig `mapstructure:"redis"` + Wechat WechatConfig `mapstructure:"wechat"` + // ... 更多配置 +} + +type ServerConfig struct { + Port int `mapstructure:"port"` + Mode string `mapstructure:"mode"` + ReadTimeout int `mapstructure:"read_timeout"` + WriteTimeout int `mapstructure:"write_timeout"` +} + +// LoadConfig 从文件加载配置 +func LoadConfig(path string) (*Config, error) { + viper.SetConfigFile(path) + viper.SetConfigType("yaml") + + if err := viper.ReadInConfig(); err != nil { + return nil, err + } + + var cfg Config + if err := viper.Unmarshal(&cfg); err != nil { + return nil, err + } + + return &cfg, nil +} +``` + +**2. 数据库连接池** + +```go +// AI生成的数据库初始化代码(pkg/database/mysql.go) +package database + +import ( + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var DB *gorm.DB + +// Init 初始化数据库连接 +func Init(cfg *config.DatabaseConfig) error { + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", + cfg.Username, + cfg.Password, + cfg.Host, + cfg.Port, + cfg.Database, + ) + + var err error + DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + }) + + if err != nil { + return err + } + + sqlDB, err := DB.DB() + if err != nil { + return err + } + + // 设置连接池参数 + sqlDB.SetMaxIdleConns(cfg.MaxIdleConns) + sqlDB.SetMaxOpenConns(cfg.MaxOpenConns) + sqlDB.SetConnMaxLifetime(time.Hour) + + return nil +} +``` + +**3. 缓存管理** + +```go +// AI生成的Redis缓存代码(pkg/cache/redis.go) +package cache + +import ( + "context" + "encoding/json" + "time" + "github.com/go-redis/redis/v8" +) + +type Cache struct { + client *redis.Client +} + +// New 创建新的缓存实例 +func New(addr, password string, db int) *Cache { + return &Cache{ + client: redis.NewClient(&redis.Options{ + Addr: addr, + Password: password, + DB: db, + }), + } +} + +// Get 获取缓存 +func (c *Cache) Get(ctx context.Context, key string, dest interface{}) error { + val, err := c.client.Get(ctx, key).Result() + if err != nil { + return err + } + return json.Unmarshal([]byte(val), dest) +} + +// Set 设置缓存 +func (c *Cache) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error { + data, err := json.Marshal(value) + if err != nil { + return err + } + return c.client.Set(ctx, key, data, expiration).Err() +} + +// Delete 删除缓存 +func (c *Cache) Delete(ctx context.Context, key string) error { + return c.client.Del(ctx, key).Err() +} +``` + +#### Day 31-60:业务模块批量生成 + +**AI自动化流程:** + +```mermaid +graph LR + A[PHP Model] --> B[AST解析] + B --> C[业务逻辑提取] + C --> D[Go Model生成] + D --> E[Repository层生成] + E --> F[Service层生成] + F --> G[API Handler生成] + G --> H[单元测试生成] +``` + +**示例:商品模块AI生成** + +**1. Go Model(从PHP Model自动转换)** + +```go +// internal/biz/goods/model/goods.go +package model + +import "time" + +// Goods 商品实体 +type Goods struct { + ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` + GoodsID string `json:"goods_id" gorm:"uniqueIndex;size:50"` + SiteID int64 `json:"site_id" gorm:"index"` + GoodsName string `json:"goods_name" gorm:"size:200;not null"` + GoodsImage string `json:"goods_image" gorm:"type:text"` + GoodsPrice float64 `json:"goods_price" gorm:"type:decimal(10,2);not null"` + GoodsStock int `json:"goods_stock" gorm:"default:0"` + GoodsState int `json:"goods_state" gorm:"default:1"` + GoodsDesc string `json:"goods_desc" gorm:"type:text"` + CategoryID int64 `json:"category_id" gorm:"index"` + BrandID int64 `json:"brand_id"` + LabelID int64 `json:"label_id"` + CreateTime time.Time `json:"create_time" gorm:"autoCreateTime"` + UpdateTime time.Time `json:"update_time" gorm:"autoUpdateTime"` +} + +// TableName 指定表名 +func (Goods) TableName() string { + return "goods" +} +``` + +**2. Repository层(数据访问)** + +```go +// internal/dal/goods/goods_repository.go +package goods + +import ( + "context" + "gorm.io/gorm" + "shop-go/internal/biz/goods/model" +) + +type Repository interface { + Create(ctx context.Context, goods *model.Goods) error + Update(ctx context.Context, goods *model.Goods) error + Delete(ctx context.Context, id int64) error + GetByID(ctx context.Context, id int64) (*model.Goods, error) + List(ctx context.Context, query *QueryParams) ([]*model.Goods, int64, error) +} + +type QueryParams struct { + SiteID int64 + CategoryID int64 + GoodsState int + Page int + PageSize int +} + +type repository struct { + db *gorm.DB +} + +// NewRepository 创建新的Repository +func NewRepository(db *gorm.DB) Repository { + return &repository{db: db} +} + +// Create 创建商品 +func (r *repository) Create(ctx context.Context, goods *model.Goods) error { + return r.db.WithContext(ctx).Create(goods).Error +} + +// Update 更新商品 +func (r *repository) Update(ctx context.Context, goods *model.Goods) error { + return r.db.WithContext(ctx).Save(goods).Error +} + +// Delete 删除商品 +func (r *repository) Delete(ctx context.Context, id int64) error { + return r.db.WithContext(ctx).Delete(&model.Goods{}, id).Error +} + +// GetByID 根据ID获取商品 +func (r *repository) GetByID(ctx context.Context, id int64) (*model.Goods, error) { + var goods model.Goods + err := r.db.WithContext(ctx).First(&goods, id).Error + if err != nil { + return nil, err + } + return &goods, nil +} + +// List 获取商品列表 +func (r *repository) List(ctx context.Context, query *QueryParams) ([]*model.Goods, int64, error) { + db := r.db.WithContext(ctx).Model(&model.Goods{}) + + if query.SiteID > 0 { + db = db.Where("site_id = ?", query.SiteID) + } + if query.CategoryID > 0 { + db = db.Where("category_id = ?", query.CategoryID) + } + if query.GoodsState >= 0 { + db = db.Where("goods_state = ?", query.GoodsState) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + var list []*model.Goods + offset := (query.Page - 1) * query.PageSize + if err := db.Offset(offset).Limit(query.PageSize).Find(&list).Error; err != nil { + return nil, 0, err + } + + return list, total, nil +} +``` + +**3. Service层(业务逻辑)** + +```go +// internal/biz/goods/service/goods_service.go +package service + +import ( + "context" + "errors" + "shop-go/internal/biz/goods/model" + "shop-go/internal/dal/goods" +) + +type GoodsService interface { + CreateGoods(ctx context.Context, req *CreateGoodsRequest) (*model.Goods, error) + UpdateGoods(ctx context.Context, req *UpdateGoodsRequest) error + DeleteGoods(ctx context.Context, id int64) error + GetGoods(ctx context.Context, id int64) (*model.Goods, error) + ListGoods(ctx context.Context, req *ListGoodsRequest) ([]*model.Goods, int64, error) +} + +type CreateGoodsRequest struct { + SiteID int64 `json:"site_id" binding:"required"` + GoodsName string `json:"goods_name" binding:"required"` + GoodsImage string `json:"goods_image"` + GoodsPrice float64 `json:"goods_price" binding:"required"` + GoodsStock int `json:"goods_stock"` + CategoryID int64 `json:"category_id"` + BrandID int64 `json:"brand_id"` + LabelID int64 `json:"label_id"` + GoodsDesc string `json:"goods_desc"` +} + +type UpdateGoodsRequest struct { + ID int64 `json:"id" binding:"required"` + GoodsName string `json:"goods_name"` + GoodsImage string `json:"goods_image"` + GoodsPrice float64 `json:"goods_price"` + GoodsStock int `json:"goods_stock"` + GoodsState int `json:"goods_state"` + CategoryID int64 `json:"category_id"` + BrandID int64 `json:"brand_id"` + LabelID int64 `json:"label_id"` + GoodsDesc string `json:"goods_desc"` +} + +type ListGoodsRequest struct { + SiteID int64 `form:"site_id"` + CategoryID int64 `form:"category_id"` + GoodsState int `form:"goods_state"` + Page int `form:"page" binding:"min=1"` + PageSize int `form:"page_size" binding:"min=1,max=100"` +} + +type service struct { + repo goods.Repository +} + +// NewGoodsService 创建商品服务 +func NewGoodsService(repo goods.Repository) GoodsService { + return &service{repo: repo} +} + +// CreateGoods 创建商品 +func (s *service) CreateGoods(ctx context.Context, req *CreateGoodsRequest) (*model.Goods, error) { + // 生成商品ID + goodsID := generateGoodsID(req.SiteID) + + goods := &model.Goods{ + GoodsID: goodsID, + SiteID: req.SiteID, + GoodsName: req.GoodsName, + GoodsImage: req.GoodsImage, + GoodsPrice: req.GoodsPrice, + GoodsStock: req.GoodsStock, + GoodsState: 1, // 默认为销售中 + CategoryID: req.CategoryID, + BrandID: req.BrandID, + LabelID: req.LabelID, + GoodsDesc: req.GoodsDesc, + } + + if err := s.repo.Create(ctx, goods); err != nil { + return nil, err + } + + return goods, nil +} + +// UpdateGoods 更新商品 +func (s *service) UpdateGoods(ctx context.Context, req *UpdateGoodsRequest) error { + goods, err := s.repo.GetByID(ctx, req.ID) + if err != nil { + return err + } + + // 更新字段 + if req.GoodsName != "" { + goods.GoodsName = req.GoodsName + } + if req.GoodsImage != "" { + goods.GoodsImage = req.GoodsImage + } + if req.GoodsPrice > 0 { + goods.GoodsPrice = req.GoodsPrice + } + if req.GoodsStock >= 0 { + goods.GoodsStock = req.GoodsStock + } + if req.GoodsState >= 0 { + goods.GoodsState = req.GoodsState + } + if req.CategoryID > 0 { + goods.CategoryID = req.CategoryID + } + if req.BrandID > 0 { + goods.BrandID = req.BrandID + } + if req.LabelID > 0 { + goods.LabelID = req.LabelID + } + if req.GoodsDesc != "" { + goods.GoodsDesc = req.GoodsDesc + } + + return s.repo.Update(ctx, goods) +} + +// DeleteGoods 删除商品 +func (s *service) DeleteGoods(ctx context.Context, id int64) error { + // 检查商品是否存在 + _, err := s.repo.GetByID(ctx, id) + if err != nil { + return err + } + + // TODO: 检查是否有关联订单 + + return s.repo.Delete(ctx, id) +} + +// GetGoods 获取商品详情 +func (s *service) GetGoods(ctx context.Context, id int64) (*model.Goods, error) { + return s.repo.GetByID(ctx, id) +} + +// ListGoods 获取商品列表 +func (s *service) ListGoods(ctx context.Context, req *ListGoodsRequest) ([]*model.Goods, int64, error) { + query := &goods.QueryParams{ + SiteID: req.SiteID, + CategoryID: req.CategoryID, + GoodsState: req.GoodsState, + Page: req.Page, + PageSize: req.PageSize, + } + + return s.repo.List(ctx, query) +} + +// generateGoodsID 生成商品ID +func generateGoodsID(siteID int64) string { + // 实现雪花算法或其他ID生成策略 + return "" +} +``` + +**4. API Handler层** + +```go +// internal/api/goods/handler.go +package goods + +import ( + "net/http" + "shop-go/internal/biz/goods/model" + "shop-go/internal/biz/goods/service" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service service.GoodsService +} + +// NewHandler 创建Handler +func NewHandler(svc service.GoodsService) *Handler { + return &Handler{service: svc} +} + +// CreateGoods 创建商品 +// @Summary 创建商品 +// @Tags 商品 +// @Accept json +// @Produce json +// @Param request body service.CreateGoodsRequest true "商品信息" +// @Success 200 {object} Response{data=model.Goods} +// @Router /api/goods [post] +func (h *Handler) CreateGoods(c *gin.Context) { + var req service.CreateGoodsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + goods, err := h.service.CreateGoods(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, Response{ + Code: 0, + Msg: "成功", + Data: goods, + }) +} + +// UpdateGoods 更新商品 +func (h *Handler) UpdateGoods(c *gin.Context) { + var req service.UpdateGoodsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.service.UpdateGoods(c.Request.Context(), &req); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, Response{ + Code: 0, + Msg: "成功", + }) +} + +// DeleteGoods 删除商品 +func (h *Handler) DeleteGoods(c *gin.Context) { + id := c.Param("id") + var goodsID int64 + if _, err := fmt.Sscanf(id, "%d", &goodsID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"}) + return + } + + if err := h.service.DeleteGoods(c.Request.Context(), goodsID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, Response{ + Code: 0, + Msg: "成功", + }) +} + +// GetGoods 获取商品详情 +func (h *Handler) GetGoods(c *gin.Context) { + id := c.Param("id") + var goodsID int64 + if _, err := fmt.Sscanf(id, "%d", &goodsID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"}) + return + } + + goods, err := h.service.GetGoods(c.Request.Context(), goodsID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, Response{ + Code: 0, + Msg: "成功", + Data: goods, + }) +} + +// ListGoods 获取商品列表 +func (h *Handler) ListGoods(c *gin.Context) { + var req service.ListGoodsRequest + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 设置默认值 + if req.Page == 0 { + req.Page = 1 + } + if req.PageSize == 0 { + req.PageSize = 20 + } + + list, total, err := h.service.ListGoods(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, Response{ + Code: 0, + Msg: "成功", + Data: gin.H{ + "list": list, + "total": total, + "page": req.Page, + "page_size": req.PageSize, + }, + }) +} + +// Response 统一响应格式 +type Response struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data interface{} `json:"data"` +} +``` + +**5. 自动化测试生成** + +```go +// internal/biz/goods/service/goods_service_test.go +package service_test + +import ( + "context" + "testing" + "shop-go/internal/biz/goods/model" + "shop-go/internal/biz/goods/service" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// MockRepository 模拟Repository +type MockRepository struct { + mock.Mock +} + +func (m *MockRepository) Create(ctx context.Context, goods *model.Goods) error { + args := m.Called(ctx, goods) + return args.Error(0) +} + +func (m *MockRepository) Update(ctx context.Context, goods *model.Goods) error { + args := m.Called(ctx, goods) + return args.Error(0) +} + +func (m *MockRepository) Delete(ctx context.Context, id int64) error { + args := m.Called(ctx, id) + return args.Error(0) +} + +func (m *MockRepository) GetByID(ctx context.Context, id int64) (*model.Goods, error) { + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.Goods), args.Error(1) +} + +func (m *MockRepository) List(ctx context.Context, query interface{}) ([]*model.Goods, int64, error) { + args := m.Called(ctx, query) + return args.Get(0).([]*model.Goods), args.Get(1).(int64), args.Error(2) +} + +// TestCreateGoods 测试创建商品 +func TestCreateGoods(t *testing.T) { + mockRepo := new(MockRepository) + svc := service.NewGoodsService(mockRepo) + + req := &service.CreateGoodsRequest{ + SiteID: 1, + GoodsName: "测试商品", + GoodsPrice: 99.99, + GoodsStock: 100, + } + + mockRepo.On("Create", mock.Anything, mock.AnythingOfType("*model.Goods")).Return(nil) + + goods, err := svc.CreateGoods(context.Background(), req) + + assert.NoError(t, err) + assert.NotNil(t, goods) + assert.Equal(t, "测试商品", goods.GoodsName) + mockRepo.AssertExpectations(t) +} + +// TestUpdateGoods 测试更新商品 +func TestUpdateGoods(t *testing.T) { + mockRepo := new(MockRepository) + svc := service.NewGoodsService(mockRepo) + + existingGoods := &model.Goods{ + ID: 1, + GoodsName: "原商品名", + GoodsPrice: 99.99, + } + + req := &service.UpdateGoodsRequest{ + ID: 1, + GoodsName: "新商品名", + GoodsPrice: 199.99, + } + + mockRepo.On("GetByID", mock.Anything, int64(1)).Return(existingGoods, nil) + mockRepo.On("Update", mock.Anything, mock.AnythingOfType("*model.Goods")).Return(nil) + + err := svc.UpdateGoods(context.Background(), req) + + assert.NoError(t, err) + mockRepo.AssertExpectations(t) +} +``` + +--- + +### **阶段三:第三方服务集成与测试(第61-75天)** + +#### Day 61-70:第三方SDK集成 + +```go +// 内部微信支付SDK封装 +// internal/pkg/wechat/pay.go +package wechat + +import ( + "context" + "github.com/wechatpay-apiv3/wechatpay-go/core" + "github.com/wechatpay-apiv3/wechatpay-go/services/payments" +) + +type PayClient struct { + client *core.Client +} + +func NewPayClient(mchid, serialNo, privateKey, apiv3Key string) (*PayClient, error) { + // 初始化微信支付客户端 + opts := []core.ClientOption{ + core.WithMerchantCredential(mchid, serialNo, privateKey), + core.WithAutoVerifyToken(core.AutoVerifyToken(apiV3Key)), + } + + client, err := core.NewClient(opts...) + if err != nil { + return nil, err + } + + return &PayClient{client: client}, nil +} + +// CreateNativePay 创建Native支付订单 +func (c *PayClient) CreateNativePay(ctx context.Context, req *payments.NativePayRequest) (*payments.NativePayResponse, error) { + svc := &payments.NativePayApiService{Client: c.client} + return svc.CreateNativePay(ctx, req) +} + +// QueryOrder 查询订单 +func (c *PayClient) QueryOrder(ctx context.Context, outTradeNo string) (*payments.TransactionResponse, error) { + svc := &payments.OrderApiService{Client: c.client} + return svc.QueryByOutTradeNo(ctx, outTradeNo) +} + +// Refund 退款 +func (c *PayClient) Refund(ctx context.Context, req *payments.RefundRequest) (*payments.RefundResponse, error) { + svc := &payments.RefundApiService{Client: c.client} + return svc.CreateRefund(ctx, req) +} +``` + +#### Day 71-75:集成测试与性能对比 + +```yaml +测试策略: + 单元测试: + - 覆盖率目标: 80%+ + - AI自动生成测试用例 + - Mock所有外部依赖 + + 集成测试: + - 数据库集成测试 + - Redis缓存测试 + - 第三方服务集成测试 + - API端到端测试 + + 性能测试: + - 并发测试: 1000 QPS + - 响应时间: P99 < 100ms + - 对比PHP版本性能 +``` + +--- + +### **阶段四:部署优化与文档(第76-90天)** + +#### Day 76-85:Docker容器化与部署 + +```dockerfile +# Dockerfile +FROM golang:1.21-alpine AS builder + +WORKDIR /app + +# 复制依赖文件 +COPY go.mod go.sum ./ +RUN go mod download + +# 复制源码 +COPY . . + +# 编译 +RUN CGO_ENABLED=0 GOOS=linux go build -o shop-api ./cmd/api + +# 运行阶段 +FROM alpine:latest + +RUN apk --no-cache add ca-certificates tzdata + +WORKDIR /root/ + +COPY --from=builder /app/shop-api . +COPY configs ./configs + +EXPOSE 8080 + +CMD ["./shop-api"] +``` + +```yaml +# docker-compose.yml +version: '3.8' + +services: + mysql: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: shop + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + + api: + build: . + ports: + - "8080:8080" + depends_on: + - mysql + - redis + environment: + - GIN_MODE=release + volumes: + - ./configs:/app/configs + +volumes: + mysql_data: + redis_data: +``` + +#### Day 86-90:文档生成与交付 + +```bash +# 自动生成API文档 +swag init -g cmd/api/main.go -o docs + +# 生成架构文档 +# 使用AI工具从代码提取架构信息 + +# 生成部署文档 +# 使用AI根据docker-compose生成部署说明 +``` + +--- + +## 🛠️ AI工具链配置 + +### **核心AI工具** + +```yaml +代码分析: + - PHP Parser (AST解析) + - Phan (静态分析) + - Psalm (类型检查) + - 自研依赖关系分析器 + +代码生成: + - GPT-4/ Claude (模型层生成) + - Template引擎 (Service/Handler生成) + - Mock生成器 (测试用例生成) + +测试生成: + - testify (测试框架) + - gomock (Mock生成) + - AI测试用例生成器 + +文档生成: + - swaggo (API文档) + - godoc (代码文档) + - AI架构文档生成器 +``` + +### **自动化CI/CD流程** + +```yaml +# .github/workflows/ci.yml +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Install dependencies + run: go mod download + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + files: ./coverage.out + + - name: Build + run: go build -v ./... + + - name: Run linters + uses: golangci/golangci-lint-action@v3 +``` + +--- + +## 📊 风险控制与质量保证 + +### **代码质量标准** + +```yaml +代码规范: + - gofmt (格式化) + - go vet (静态检查) + - golangci-lint (综合检查) + +测试覆盖: + - 单元测试覆盖率 > 80% + - 集成测试覆盖核心业务 + - 性能测试: P99 < 100ms + +文档要求: + - API文档自动生成 + - 代码注释完整 + - 架构设计文档 + +性能指标: + - 响应时间: P50 < 50ms, P99 < 100ms + - 吞吐量: > 1000 QPS + - 内存使用: < 500MB +``` + +### **回滚策略** + +```yaml +灰度发布: + - 第一阶段: 5% 流量 + - 第二阶段: 20% 流量 + - 第三阶段: 50% 流量 + - 第四阶段: 100% 流量 + +监控告警: + - 错误率 > 1% 立即告警 + - 响应时间 P99 > 200ms 告警 + - 内存使用 > 80% 告警 + +回滚条件: + - 错误率持续上升 + - 性能严重下降 + - 核心业务异常 +``` + +--- + +## 💰 成本估算 + +```yaml +AI工具成本: + - OpenAI API: $300-500/月 + - Claude API: $200-300/月 + - 总计: $500-800/月 + +云服务成本: + - 服务器: $100-200/月 + - 数据库: $100-150/月 + - 存储: $50/月 + - 总计: $250-400/月 + +总计: $750-1200/月 (约5000-8000元/月) +``` + +--- + +## 🎯 预期成果 + +### **交付物清单** + +```yaml +代码交付: + ✅ 完整Go代码库 + ✅ 单元测试覆盖率 > 80% + ✅ 集成测试套件 + ✅ API文档 + +部署交付: + ✅ Docker镜像 + ✅ Docker Compose配置 + ✅ Kubernetes部署文件 + ✅ 监控配置 + +文档交付: + ✅ 架构设计文档 + ✅ API接口文档 + ✅ 部署运维文档 + ✅ 代码注释文档 +``` + +### **性能提升预期** + +```yaml +性能对比: + 响应时间: 提升 3-5倍 + 并发能力: 提升 10倍+ + 内存占用: 降低 30-50% + CPU利用率: 提升 2-3倍 + +稳定性提升: + - 更好的错误处理 + - 更强的类型安全 + - 更完善的监控 + - 更快的部署速度 +``` + +--- + +## 🚀 开始实施 + +### **立即行动清单** + +```bash +# 第1周任务 +1. 设置AI开发环境 +2. 配置代码分析工具链 +3. 运行PHP代码分析 +4. 生成项目架构文档 + +# 第2周任务 +5. 创建Go项目结构 +6. 配置数据库连接 +7. 实现基础框架代码 +8. 设置CI/CD流程 + +# 第3-4周任务 +9. 开始批量生成Model层 +10. 生成Repository层 +11. 生成Service层 +12. 生成API Handler层 +``` + +--- + +## 📞 技术支持 + +如果您决定启动这个纯AI重构项目,我建议: + +1. **前期准备(1-2天)**:详细分析PHP代码,建立完整的技术债务清单 +2. **架构评审(3天)**:确保Go架构设计满足业务需求 +3. **分阶段交付**:按模块逐步交付,每2周一个里程碑 +4. **持续监控**:建立完善的监控和告警机制 +5. **性能基准**:建立PHP版本的性能基准,用于对比验证 + +--- + +**总结:通过这个纯AI重构方案,您可以在3个月内将整个PHP电商系统重写为Go语言,同时保证代码质量、测试覆盖率和生产稳定性。关键在于充分利用AI工具的自动化能力,建立完善的测试和监控体系。** + +您是否需要我详细展开某个具体阶段的技术细节,或者开始准备第一阶段的代码分析工作? \ No newline at end of file diff --git a/docs/diy/RADEME.md b/docs/diy/RADEME.md index c9a6a0b2a..4d8678fb9 100644 --- a/docs/diy/RADEME.md +++ b/docs/diy/RADEME.md @@ -24,4 +24,44 @@ create table if not exists lucky_diy_view_util constraint name unique (name) ) -``` \ No newline at end of file +``` + +## 页面设计及组件展示 + +- src\app\model\web\DiyView.php +- src\app\shop\view\diy\edit.html +- src\public\static\ext\diyview\js\components.js + +## 如何添加新组件 + + +### 1. 添加组件到数据表中 + +```sql +insert into lucky_diy_view_util (name, title, type, value, addon_name, sort, support_diy_view, max_count, is_delete, icon, icon_type) +values ('test', '测试', 'SYSTEM', '{"test": "test"}', '', 0, '', 0, 0, '', 0); + +--- 微信视频号 +-- 仅当WechatChannel不存在时添加记录 +INSERT INTO lucky_diy_view_util (`name`, `title`, `type`, `value`, `addon_name`, `sort`, `support_diy_view`, `max_count`, `is_delete`, `icon`, `icon_type`) +SELECT 'WechatChannel', '微信视频号', 'SYSTEM', '{ "list": [{ "channelName":"", "finderUserName": "", "avatarImageType": "url", "avatarUrl": "", "videoTitle": "", "coverImageType": "url", "coverUrl": "", "feedId": "", "feedToken": "", "viewCount": 0, "showViewCount": true, "embedMode": false, "channelType":"wechat" }], "rowCount": 2, "showStyle": "fixed", "aspectRatio":"16:9", "titleLineClamp": 1, "showPlayBtn": true}', '', 100110, '', 0, 0, '/public/static/img/svg/xuanxiangka.svg', 0 +WHERE NOT EXISTS ( + SELECT 1 FROM lucky_diy_view_util WHERE name = 'WechatChannel' +); + +``` + + +### 2. 建立组件的控制器 +在 `src\app\component\controller` 目录下创建对应的控制器文件,处理组件的业务逻辑。 + +例如:创建 `src\app\component\controller\TestController.php` 文件,用于处理测试组件的业务逻辑。 + +### 3. 建立组件的视图 +在 src\app\component\view 目录下创建对应的视图文件,处理组件的前端展示。 + +例如:创建 `src\app\component\view\test.php` 文件,用于展示测试组件。 + + +### 4. 在前端页面中使用组件 +在前端页面中使用组件,需要在页面中添加对应的组件标签。 \ No newline at end of file diff --git a/src/addon/personnel/data/upgrade_add_channel_fields.sql b/src/addon/personnel/data/upgrade_add_channel_fields.sql index f58e16a0a..4291589a5 100644 --- a/src/addon/personnel/data/upgrade_add_channel_fields.sql +++ b/src/addon/personnel/data/upgrade_add_channel_fields.sql @@ -10,13 +10,13 @@ ADD COLUMN `show_follow` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否显示关 -- 2. 优化现有字段长度和类型 ALTER TABLE `lucky_personnel_channel` -MODIFY COLUMN `channel_type` varchar(20) NOT NULL DEFAULT 'wechat' COMMENT '视频号类型:wechat-微信视频号,douyin-抖音,kuaishou-快手,redbook-小红书,bilibili-B站', -MODIFY COLUMN `channel_name` varchar(100) NOT NULL DEFAULT '' COMMENT '视频号名称', -MODIFY COLUMN `avatar_image_type` varchar(10) NOT NULL DEFAULT 'upload' COMMENT '头像类型:upload-上传图片,url-URL', +MODIFY COLUMN `channel_type` varchar(30) NOT NULL DEFAULT 'wechat' COMMENT '视频号类型:wechat-微信视频号,douyin-抖音,kuaishou-快手,redbook-小红书,bilibili-B站', +MODIFY COLUMN `channel_name` varchar(200) NOT NULL DEFAULT '' COMMENT '视频号名称', +MODIFY COLUMN `avatar_image_type` varchar(20) NOT NULL DEFAULT 'upload' COMMENT '头像类型:upload-上传图片,url-URL', MODIFY COLUMN `avatar_url` varchar(500) NOT NULL DEFAULT '' COMMENT '头像URL', -MODIFY COLUMN `video_title` varchar(200) NOT NULL DEFAULT '' COMMENT '视频标题', -MODIFY COLUMN `feed_id` varchar(100) NOT NULL DEFAULT '' COMMENT '视频号内容ID', -MODIFY COLUMN `cover_image_type` varchar(10) NOT NULL DEFAULT 'upload' COMMENT '封面类型:upload-上传图片,url-URL', +MODIFY COLUMN `video_title` varchar(500) NOT NULL DEFAULT '' COMMENT '视频标题', +MODIFY COLUMN `feed_id` varchar(500) NOT NULL DEFAULT '' COMMENT '视频号内容ID', +MODIFY COLUMN `cover_image_type` varchar(20) NOT NULL DEFAULT 'upload' COMMENT '封面类型:upload-上传图片,url-URL', MODIFY COLUMN `cover_url` varchar(500) NOT NULL DEFAULT '' COMMENT '封面URL', MODIFY COLUMN `sort` int NOT NULL DEFAULT 0 COMMENT '排序,数值越小越靠前', MODIFY COLUMN `is_show` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否显示:0-隐藏,1-显示', diff --git a/src/addon/personnel/shop/view/enterprise/channel/edit.html b/src/addon/personnel/shop/view/enterprise/channel/edit.html index ac9b63027..4747d7391 100644 --- a/src/addon/personnel/shop/view/enterprise/channel/edit.html +++ b/src/addon/personnel/shop/view/enterprise/channel/edit.html @@ -4,6 +4,7 @@
@@ -14,28 +15,52 @@
- -
- + +
+
-
- +
微信视频号ID获取方式:进入视频号助手,在首页可以查看自己的视频号ID,以sph开头为视频号ID
+
- -
- + +
+ +
+
+

不同视频平台的唯一标识获取方式可能不同,请根据具体平台说明获取

+

微信视频号,要填写FeedID的值;

+
+
+ +
+ +
+
+

不同视频平台的唯一标识获取方式可能不同,请根据具体平台说明获取

+

微信视频号,要填写视频的FeedToken值;仅内嵌小程序非同主体视频号视频时使用。

+ +
+
+ +
+ +
+ +
+
+
@@ -68,19 +93,7 @@
推荐使用 750x420 像素的图片
-
- -
- -
-
- -
- -
- -
-
+
@@ -148,7 +161,7 @@
-
+ +
diff --git a/src/addon/personnel/shop/view/public/diy.css b/src/addon/personnel/shop/view/public/diy.css index e69de29bb..a12e88f79 100644 --- a/src/addon/personnel/shop/view/public/diy.css +++ b/src/addon/personnel/shop/view/public/diy.css @@ -0,0 +1,32 @@ +.collapse-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 15px; + background-color: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 4px; + cursor: pointer; + margin-bottom: 10px; +} + +.collapse-header:hover { + background-color: #e9ecef; +} + +.collapse-title { + font-weight: 600; + color: #333; +} + +.collapse-content { + padding: 10px 15px; + border-radius: 4px; + background-color: #fff; + box-sizing: border-box; + border: 1px dashed #8a8a8a; +} + +.channel-settings .layui-form-item { + margin-bottom: 15px; +} diff --git a/src/addon/personnel/shop/view/public/diy.js b/src/addon/personnel/shop/view/public/diy.js index c03031e20..55cc08b6d 100644 --- a/src/addon/personnel/shop/view/public/diy.js +++ b/src/addon/personnel/shop/view/public/diy.js @@ -3,7 +3,7 @@ */ var vue = new Vue({ el: "#diyView", - data: { + data: () => ({ lazyLoad: false, global: { title: "电子名片", @@ -13,31 +13,44 @@ var vue = new Vue({ textNavColor: "#333333", textImgPosLink: 'center', }, - is_kefu:1, + is_kefu: 1, is_mp: 1, is_file: 1, is_video: 1, is_channel: 1, is_map: 1, - }, + // 视频号设置 + channel_display_style: 'fixed', // 显示样式:carousel, fixed, singleSlide,默认fixed + channel_aspect_ratio: '16:9', // 显示比例:16:9 和 3:4,默认16:9 + channel_show_view_count: true, // 是否显示观看次数,默认显示 + channel_row_count: 2, // 每行显示数量:1,2,3,4,默认2 + channel_title_line_clamp: 2, // 标题最多行数:1,2,3,默认2 + channel_show_play_btn: true, // 是否显示播放按钮,默认显示 + channelCollapsed: false, // 视频号设置是否折叠,默认展开 + }), created: function () { if ($("#guessYouLikeConfig").val()) { $('#diyView').css('visibility', 'visible'); $('.preview-wrap .preview-restore-wrap').css('visibility', 'visible'); var self = this; - setTimeout(() => { - var data = JSON.parse($("#guessYouLikeConfig").val()); - console.log(this.data) - this.is_kefu = data.is_kefu - this.is_mp = data.is_mp - this.is_file = data.is_file - this.is_video = data.is_video - this.is_map = data.is_map - this.is_channel = data.is_channel - fullScreenSize(function () { - self.lazyLoad = true; - }); - }, 10); + + var data = JSON.parse($("#guessYouLikeConfig").val()); + this.is_kefu = data.is_kefu; + this.is_mp = data.is_mp; + this.is_file = data.is_file; + this.is_video = data.is_video; + this.is_map = data.is_map; + this.is_channel = data.is_channel; + // 视频号设置初始化 + this.channel_display_style = data.channel_display_style || 'fixed'; + this.channel_aspect_ratio = data.channel_aspect_ratio || '16:9'; + this.channel_show_view_count = !!data.channel_show_view_count; + this.channel_row_count = data.channel_row_count || 2; + this.channel_title_line_clamp = data.channel_title_line_clamp || 2; + this.channel_show_play_btn = !!data.channel_show_play_btn; + fullScreenSize(function () { + self.lazyLoad = true; + }); } else { $('#diyView').css('visibility', 'visible'); $('.preview-wrap .preview-restore-wrap').css('visibility', 'visible'); @@ -75,6 +88,47 @@ layui.use(['form'], function () { form.render(); fullScreenSize(); + + // 初始化layui的select元素的默认值和事件绑定 + const initLayuiSelectBindings = (() => { + // 特别注意:layui的select元素,不能使用v-model绑定数据, 只能使用layui的form.on('select')事件监听和form.val()方法设置默认值 + [`channel_display_style`, `channel_aspect_ratio`, `channel_row_count`, `channel_title_line_clamp`].forEach((key) => { + // 根据vue数据, 初始化layui的select元素的默认值 + if (vue.hasOwnProperty(key)) { + // form.val 必须指定form的lay-filter属性值,才能设置默认值 + form.val('diy-form', { + [`${key}`]: vue[key] + }); + console.log(`form.val(${key}, ${vue[key]})`); + } + + // 监听select事件, 并更新vue数据, layui + vue. 针对select元素,不能使用v-model绑定数据, 只能使用layui的form.on('select')事件监听 + form.on('select(' + key + ')', function (data) { + if (vue.hasOwnProperty(key)) { + vue[key] = data.value; + console.log(`vue.${key} = ${data.value}`); + } + }); + }); + }); + + initLayuiSelectBindings(); // 启动默认绑定事件一次 + + // FIX: 当切换视频号隐藏还是显示时,需要重新渲染layui的select元素 + // 监听视频号设置是否折叠 + form.on('switch(channelCollapsed)', function (data) { + vue.channelCollapsed = data.elem.checked; + form.render('select'); + initLayuiSelectBindings(); + }); + + // FIX: 当切换视频号隐藏还是显示时,需要重新渲染layui的select元素,重新渲染后,需要重新绑定初始化数据和事件 + vue.$watch('is_channel', function (newVal, oldVal) { + if (newVal !== oldVal) { + form.render('select'); + initLayuiSelectBindings(); + } + }); $("body").off("click", ".edit-attribute .attr-wrap .restore-wrap .attr-title .tab-wrap span").on("click", ".edit-attribute .attr-wrap .restore-wrap .attr-title .tab-wrap span", function () { $(this).addClass('active bg-color').siblings().removeClass('active bg-color'); @@ -85,15 +139,23 @@ layui.use(['form'], function () { }); form.on('submit(save)', function (data) { + console.log('submit save:', { data, vue }); if (repeat_flag) return; repeat_flag = true; var formData = { - is_kefu:vue.is_kefu, - is_mp:vue.is_mp, - is_file:vue.is_file, - is_video:vue.is_video, - is_channel:vue.is_channel, - is_map:vue.is_map + is_kefu: vue.is_kefu, + is_mp: vue.is_mp, + is_file: vue.is_file, + is_video: vue.is_video, + is_channel: vue.is_channel, + is_map: vue.is_map, + // 视频号设置 + channel_display_style: vue.channel_display_style, + channel_aspect_ratio: vue.channel_aspect_ratio, + channel_show_view_count: vue.channel_show_view_count, + channel_row_count: vue.channel_row_count, + channel_title_line_clamp: vue.channel_title_line_clamp, + channel_show_play_btn: vue.channel_show_play_btn }; $.ajax({ type: "post", diff --git a/src/app/api/controller/Member.php b/src/app/api/controller/Member.php index a427047fa..f2f5038a6 100644 --- a/src/app/api/controller/Member.php +++ b/src/app/api/controller/Member.php @@ -587,6 +587,19 @@ class Member extends BaseApi $config['value'] = json_decode($config['value'],true); return $this->response(['code'=>'0','data'=>$list,'message'=>'操作成功','shop'=>$shop_info_result,'set'=>$set,'channel_list'=>$channel_list, 'video_list'=>$video_list,'file_list'=>$file_list,'diy'=>$config['value']]); } + /** + * 视频号观看次数+1 + */ + public function incrementChannelViewCount() + { + $id = $this->params[ 'channel_id' ] ?? 0; + if (empty($id)) { + return $this->response($this->error('', '未传视频号id!')); + } + $cur_view_count = model('personnel_channel')->getValue(['channel_id'=>$id], 'view_count', 0); + $res = model('personnel_channel')->update(['view_count'=>$cur_view_count+1],['channel_id'=>$id]); + return $this->response(['code'=>'0','message'=>'操作成功', 'data'=>$res, 'channel_id'=>$id, 'pre_view_count'=>$cur_view_count]); + } //留言 public function message() { diff --git a/src/app/component/controller/WechatChannel.php b/src/app/component/controller/WechatChannel.php new file mode 100644 index 000000000..f967745cc --- /dev/null +++ b/src/app/component/controller/WechatChannel.php @@ -0,0 +1,17 @@ +fetch("wechat_channel/design.html"); + } +} \ No newline at end of file diff --git a/src/app/component/view/wechat_channel/css/design.css b/src/app/component/view/wechat_channel/css/design.css new file mode 100644 index 000000000..654f3e318 --- /dev/null +++ b/src/app/component/view/wechat_channel/css/design.css @@ -0,0 +1,315 @@ +.wechat_channel-box .preview-draggable { + padding: 15px; +} + +.wechat_channel-box .video-list-wrap { + position: relative; +} + +.wechat_channel-box .video-list { + display: grid; + grid-template-columns: repeat(var(--row-count, 2), 1fr); + gap: 8px; + padding: 16px 16px 0px; +} + +.wechat_channel-box .video-item { + position: relative; + width: 100%; +} + +.wechat_channel-box .video-wrap { + position: relative; +} + +.wechat_channel-box .video-wrap .channel-preview { + position: relative; + width: 100%; + overflow: hidden; + border-radius: 5px; +} + +.wechat_channel-box .video-item .channel-preview img { + display: block; + width: 100%; + object-fit: cover; + border-radius: 5px; +} + +.wechat_channel-box .video-item .channel-preview { + position: relative; + width: 100%; + overflow: hidden; + border-radius: 5px; +} + +.wechat_channel-box .video-item .play-btn { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 40px; + height: 40px; + background: rgba(0, 0, 0, 0.6); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.wechat_channel-box .video-item .play-btn::before { + content: ''; + width: 0; + height: 0; + border-style: solid; + border-width: 10px 0 10px 16px; + border-color: transparent transparent transparent #fff; + margin-left: 2px; +} + +.wechat_channel-box .video-item .view-count { + position: absolute; + right: 2px; + font-size: 12px; + color: #fff; + padding: 4px 4px; + /* border-radius: 16px; */ + margin-bottom: 0; + z-index: 10; + text-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5); +} + +.wechat_channel-box .video-item .channel-info { + position: relative; + padding: 8px; + background: #fff; + border-radius: 0 0 8px 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + margin-top: -4px; +} + +.wechat_channel-box .video-item .channel-avatar { + display: none; +} + +.wechat_channel-box .video-item .channel-text { + flex: 1; + overflow: hidden; +} + +.wechat_channel-box .video-item .channel-name { + display: none; +} + +.wechat_channel-box .video-item .video-title { + font-size: 13px; + font-weight: 500; + color: #333; + overflow: hidden; + margin-bottom: 3px; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-box-orient: vertical; + word-break: break-all; + word-wrap: break-word; + white-space: normal; +} + +/* 1行标题 */ +.wechat_channel-box .video-item .video-title.title-max-lines-1 { + -webkit-line-clamp: 1; +} + +/* 2行标题 */ +.wechat_channel-box .video-item .video-title.title-max-lines-2 { + -webkit-line-clamp: 2; + margin-bottom: 4px; +} + +/* 3行标题 */ +.wechat_channel-box .video-item .video-title.title-max-lines-3 { + -webkit-line-clamp: 3; + margin-bottom: 4px; +} + +.wechat_channel-box .video-item .bottom-text { + font-size: 14px; + color: #666; + margin-top: 8px; + line-height: 1.4; + font-style: normal; +} + +.wechat_channel-box .video-item .video-placeholder { + height: 200px; + background: #f5f5f5; + display: flex; + align-items: center; + justify-content: center; + border: 1px dashed #ddd; + border-radius: 5px; +} + +.wechat_channel-box .video-item .placeholder-text { + color: #999; + font-size: 14px; +} + +/* 轮播样式 */ +.wechat_channel-box .video-carousel-wrap { + position: relative; + width: 100%; + overflow: hidden; +} + +.wechat_channel-box .video-carousel { + position: relative; + width: 100%; + overflow: hidden; +} + +.wechat_channel-box .carousel-item { + position: absolute; + top: 0; + left: 0; + width: 100%; + opacity: 0; + transition: opacity 0.5s ease-in-out; + z-index: 1; +} + +.wechat_channel-box .carousel-item.active { + position: relative; + opacity: 1; + z-index: 2; +} + +.wechat_channel-box .carousel-indicators { + position: relative; + margin-top: 8px; + margin-bottom: 8px; + margin-left: auto; + margin-right: auto; + width: fit-content; + display: flex; + gap: 8px; + z-index: 10; +} + +.wechat_channel-box .carousel-indicators .indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: rgba(255, 255, 255, 0.5); + cursor: pointer; + transition: background-color 0.3s ease; +} + +.wechat_channel-box .carousel-indicators .indicator.active { + background-color: #000000; + transform: scale(1.2); +} + +/* 轮播模式下的视频项样式 */ +.wechat_channel-box .video-carousel-wrap .video-item { + width: 100%; + max-width: 100%; +} + +/* 轮播模式下的通道预览样式 */ +.wechat_channel-box .video-carousel-wrap .channel-preview { + width: 100%; +} + +/* 16:9比例下的固定高度 */ +/* 一行显示1个 */ +.wechat_channel-box[data-ratio="16:9"] { + --image-height: 212px; +} +/* 一行显示2个 */ +.wechat_channel-box[data-ratio="16:9"][data-row-count="2"] { + --image-height: 104px; +} +/* 一行显示3个 */ +.wechat_channel-box[data-ratio="16:9"][data-row-count="3"] { + --image-height: 65px; +} +/* 一行显示4个 */ +.wechat_channel-box[data-ratio="16:9"][data-row-count="4"] { + --image-height: 47px; +} + +/* 3:4比例下的固定高度 */ +/* 一行显示1个 */ +.wechat_channel-box[data-ratio="3:4"] { + --image-height: 408px; +} +/* 一行显示2个 */ +.wechat_channel-box[data-ratio="3:4"][data-row-count="2"] { + --image-height: 200px; +} +/* 一行显示3个 */ +.wechat_channel-box[data-ratio="3:4"][data-row-count="3"] { + --image-height: 130px; +} +/* 一行显示4个 */ +.wechat_channel-box[data-ratio="3:4"][data-row-count="4"] { + --image-height: 96px; +} + +.wechat_channel-box .video-item .channel-preview img { + height: var(--image-height); +} + +.wechat_channel-box .video-item .view-count { + top: calc(var(--image-height) - 32px); +} + +.wechat_channel-box .edit-attribute .attr-wrap .restore-wrap .video-add-box .img-block { + width: 200px !important; + height: 125px !important; + margin-bottom: 30px; + margin-right: 0; + position: relative; +} + +.wechat_channel-box .edit-attribute .attr-wrap .restore-wrap .video-add-box .img-block>div { + line-height: 125px; + height: 125px !important; + width: 100%; + text-align: center; +} + +.wechat_channel-box .edit-attribute .attr-wrap .restore-wrap .video-add-box .img-block video { + width: 100% !important; + height: 125px !important; +} + +.wechat_channel-box .edit-attribute .attr-wrap .restore-wrap .video-add-box .img-block span { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.wechat_channel-box .video-zhezhao { + position: absolute; + background: #fff; + width: 61%; + height: 125px; + top: 1px; + right: 32px; + text-align: center; + line-height: 105px; + display: none; +} + +.wechat_channel-box .video-zhezhao span { + position: absolute; + top: 35px; + left: 80px; + color: #909399; +} + +/* .wechat_channel-box .layui-form-label + .layui-input-block {margin-left: 0 !important;} */ \ No newline at end of file diff --git a/src/app/component/view/wechat_channel/design.html b/src/app/component/view/wechat_channel/design.html new file mode 100644 index 000000000..68c8e001f --- /dev/null +++ b/src/app/component/view/wechat_channel/design.html @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/app/component/view/wechat_channel/js/design.js b/src/app/component/view/wechat_channel/js/design.js new file mode 100644 index 000000000..7d3cc8832 --- /dev/null +++ b/src/app/component/view/wechat_channel/js/design.js @@ -0,0 +1,563 @@ +Vue.component("wechat_channel-edit", { + template: `
+
+

容器设置

+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+
+ +
+

视频列表

+ + +
+
+
+ 拖拽排序 +
+
+

视频 {{ index + 1 }}

+ +
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ +
+
+ + +
+ +
+
+
+
+ +

点击上传

+
+
+ + x +
+
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ +
+
+ + +
+ +
+
+
+
+ +

点击上传

+
+
+ + x +
+
+
推荐使用 200x200 像素的图片
+
+
+
+ + + +
+
`, + data: function () { + return { + data: this.$parent.data + }; + }, + + + created: function () { + if (!this.$parent.data.verify) this.$parent.data.verify = []; + this.$parent.data.verify.push(this.verify);//加载验证方法 + + this.$parent.data.ignore = ['textColor', 'componentBgColor', 'elementBgColor', 'elementAngle'];//加载忽略内容 -- 其他设置中的属性设置 + this.$parent.data.ignoreLoad = true; // 等待忽略数组赋值后加载 + }, + mounted: function () { + console.log("wechat_channel-edit", this.data) + + // 初始化数据结构 + if (!this.data.list) { + this.data.list = []; + } + + if (!this.data.rowCount) { + this.data.rowCount = 2; + } + + if (!this.data.showStyle) { + this.data.showStyle = 'fixed'; + } + + if (!this.data.aspectRatio) { + this.data.aspectRatio = '16:9'; + } + + if (!this.data.titleLineClamp) { + this.data.titleLineClamp = 1; + } + + if (this.data.showPlayBtn === undefined) { + this.data.showPlayBtn = true; + } + + // 初始化轮播幻灯片索引 + if (this.data.currentSlide === undefined) { + this.data.currentSlide = 0; + } + + // 初始化分组列表 + this.updateGroupedList(); + + // 初始化列表项 + this.data.list.forEach((item, index) => { + if (!item.avatarImageType) { + item.avatarImageType = 'url'; + } else if (item.avatarUrl && item.avatarImageType == 'upload') { + var val = '头像'; + $("#avatarImg_" + index).html(val); + } + + if (!item.coverImageType) { + item.coverImageType = 'url'; + } else if (item.coverUrl && item.coverImageType == 'upload') { + var val = '封面'; + $("#coverImg_" + index).html(val); + } + + if (item.showViewCount === undefined) { + item.showViewCount = true; + } + }); + + layui.use(['form'], () => { + var form = layui.form; + form.render(); + + // 头像类型切换 + form.on('radio(avatarType)', (data) => { + const elem = data.elem; + const type = elem.value; + const index = elem.dataset.index; + + if (index !== undefined && this.data.list[index]) { + this.data.list[index].avatarImageType = type; + + this.$nextTick(() => { + if (type == 'upload' && this.data.list[index].avatarUrl) { + var val = '头像'; + $("#avatarImg_" + index).html(val); + } + }); + } + }); + + // 封面类型切换 + form.on('radio(coverImageType)', (data) => { + const elem = data.elem; + const type = elem.value; + const index = elem.dataset.index; + + if (index !== undefined && this.data.list[index]) { + this.data.list[index].coverImageType = type; + + this.$nextTick(() => { + if (type == 'upload' && this.data.list[index].coverUrl) { + var val = '封面'; + $("#coverImg_" + index).html(val); + } + }); + } + }); + + // 封面比例切换 + form.on('radio(aspectRatio)', (data) => { + const elem = data.elem; + const ratio = elem.value; + this.data.aspectRatio = ratio; + }); + + // 标题最多行数切换 + form.on('select(titleLineClamp)', (data) => { + const lines = parseInt(data.value); + this.data.titleLineClamp = lines; + }); + + // 每行显示数量切换 + form.on('select(rowCount)', (data) => { + const count = parseInt(data.value); + this.data.rowCount = count; + // 更新分组列表 + this.updateGroupedList(); + // 重新初始化轮播功能,确保根据新的rowCount重新分组 + this.initCarousel(); + }); + + // 显示风格切换 + form.on('select(showStyle)', (data) => { + const style = data.value; + this.data.showStyle = style; + // 更新分组列表 + this.updateGroupedList(); + // 重新初始化轮播功能 + this.initCarousel(); + }); + + // 显示播放按钮切换 + form.on('switch(showPlayBtn)', (data) => { + const elem = data.elem; + const checked = elem.checked; + this.data.showPlayBtn = checked; + }); + + // 显示观看次数切换 + form.on('switch', (data) => { + const elem = data.elem; + const checked = elem.checked; + // 查找对应的视频号索引 + const videoItem = elem.closest('.video-item-edit'); + if (videoItem) { + const index = videoItem.dataset.index; + if (index !== undefined && this.data.list[index]) { + this.data.list[index].showViewCount = checked; + this.$forceUpdate(); + } + } + }); + }); + + // 初始化拖拽排序 + this.initSortable(); + + // 初始化轮播自动切换 + this.initCarousel(); + }, + methods: { + verify: function () { + var res = { code: true, message: "" }; + // 微信视频号组件暂不需要强制验证 + return res; + }, + + // 上传封面图片 + uploadCover: function (index) { + + // 从媒体库中选择 + openAlbum((data) => { + const imgUrl = data[0].pic_path; + var val = '封面'; + $("#coverImg_" + index).html(val); + + if (this.data.list[index]) { + this.data.list[index].coverUrl = imgUrl; + } + }, 1); + }, + + // 上传头像图片 + uploadAvatar: function (index) { + + // 从媒体库中选择 + openAlbum((data) => { + const imgUrl = data[0].pic_path; + var val = '头像'; + $("#avatarImg_" + index).html(val); + + if (this.data.list[index]) { + this.data.list[index].avatarUrl = imgUrl; + } + }, 1); + }, + + // 添加视频号 + addVideoItem: function () { + this.data.list.push({ + "channelType": "wechat", + "channelName": "", + "finderUserName": "", + "avatarImageType": "url", + "avatarUrl": "", + "videoTitle": "", + "coverImageType": "url", + "coverUrl": "", + "feedId": "", + "feedToken": "", + "viewCount": 0, + "showViewCount": true, + "embedMode": false + }); + this.$forceUpdate(); + this.$parent.$forceUpdate(); + + // 重新渲染表单并绑定事件 + this.$nextTick(() => { + layui.use(['form'], () => { + var form = layui.form; + form.render(); + + // 重新绑定头像类型切换事件 + form.on('radio(avatarType)', (data) => { + const elem = data.elem; + const type = elem.value; + const index = elem.dataset.index; + + if (index !== undefined && this.data.list[index]) { + this.data.list[index].avatarImageType = type; + this.$forceUpdate(); + + this.$nextTick(() => { + if (type == 'upload' && this.data.list[index].avatarUrl) { + var val = '头像'; + $("#avatarImg_" + index).html(val); + } + }); + } + }); + + // 重新绑定封面类型切换事件 + form.on('radio(coverImageType)', (data) => { + const elem = data.elem; + const type = elem.value; + const index = elem.dataset.index; + + if (index !== undefined && this.data.list[index]) { + this.data.list[index].coverImageType = type; + this.$forceUpdate(); + + this.$nextTick(() => { + if (type == 'upload' && this.data.list[index].coverUrl) { + var val = '封面'; + $("#coverImg_" + index).html(val); + } + }); + } + }); + }); + + // 重新初始化拖拽排序 + this.initSortable(); + }); + }, + + // 删除视频号 + removeVideoItem: function (index) { + this.data.list.splice(index, 1); + this.$forceUpdate(); + this.$parent.$forceUpdate(); + + // 重新初始化拖拽排序 + this.$nextTick(() => { + this.initSortable(); + }); + }, + + // 初始化拖拽排序 + initSortable: function () { + // 检查Sortable库是否已加载 + if (typeof Sortable !== 'undefined') { + const videoList = document.getElementById('videoListEdit'); + if (videoList) { + // 销毁现有实例 + if (this.sortableInstance) { + this.sortableInstance.destroy(); + } + + // 创建新的Sortable实例 + this.sortableInstance = new Sortable(videoList, { + handle: '.drag-handle', + animation: 150, + onEnd: (evt) => { + // 获取拖拽前后的索引 + const oldIndex = evt.oldIndex; + const newIndex = evt.newIndex; + + // 重新排序数组 + if (oldIndex !== newIndex) { + const [movedItem] = this.data.list.splice(oldIndex, 1); + this.data.list.splice(newIndex, 0, movedItem); + + // 强制更新视图 + this.$forceUpdate(); + this.$parent.$forceUpdate(); + + // 重新渲染表单 + layui.use(['form'], () => { + var form = layui.form; + form.render(); + }); + } + } + }); + } + } else { + // 如果Sortable库未加载,尝试动态加载 + const script = document.createElement('script'); + script.src = 'https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js'; + script.onload = () => { + this.initSortable(); + }; + document.head.appendChild(script); + } + }, + + // 更新分组列表 + updateGroupedList: function () { + const list = this.data.list || []; + const rowCount = this.data.rowCount || 2; + const groups = []; + + for (let i = 0; i < list.length; i += rowCount) { + groups.push(list.slice(i, i + rowCount)); + } + + this.data.groupedList = groups; + console.log("groupedList", this.data.groupedList); + }, + + // 初始化轮播自动切换 + initCarousel: function () { + // 清除现有的轮播定时器 + if (this.carouselTimer) { + clearInterval(this.carouselTimer); + } + + // 更新分组列表 + this.updateGroupedList(); + + // 只有当显示风格为carousel且有多个分组时才启动轮播 + if (this.data.showStyle === 'carousel' && this.data.groupedList && this.data.groupedList.length > 1) { + this.carouselTimer = setInterval(() => { + this.data.currentSlide = (this.data.currentSlide + 1) % this.data.groupedList.length; + }, 3000); // 每3秒切换一次 + } + } + }, +}); \ No newline at end of file diff --git a/src/app/shop/view/diy/iconfont_component.html b/src/app/shop/view/diy/iconfont_component.html index 7826692bd..3c7adc6ce 100644 --- a/src/app/shop/view/diy/iconfont_component.html +++ b/src/app/shop/view/diy/iconfont_component.html @@ -65,7 +65,7 @@ }, iconStyle(){ if (!this.value) return {}; - console.log(this.value) + // console.log(this.value) var style = { fontSize: this.value.fontSize + '%' } diff --git a/src/public/static/ext/diyview/css/diyview.css b/src/public/static/ext/diyview/css/diyview.css index 9f74cc580..55d52dcb1 100644 --- a/src/public/static/ext/diyview/css/diyview.css +++ b/src/public/static/ext/diyview/css/diyview.css @@ -149,7 +149,7 @@ /* 选中弹窗广告组件 */ .pop-window-wrap.selected .edit-attribute{display: block;} -.edit-attribute .attr-wrap {width: 392px;overflow-x: hidden;overflow-y: auto;height: 600px;} +.edit-attribute .attr-wrap {width: 392px;overflow-x: hidden;overflow-y: auto;min-height: 200px;max-height: calc(100vh - 150px);} .edit-attribute .attr-wrap .restore-wrap {width: 360px;} .edit-attribute .attr-wrap .restore-wrap .layui-form-label {color: #666 !important;} .edit-attribute .attr-wrap .restore-wrap .attr-title {padding: 10px 0 15px 10px;border-bottom: 2px solid #f2f4f6;margin-bottom: 10px;color: #303133;display: flex;justify-content: space-between;align-items: center;} diff --git a/src/public/static/ext/diyview/js/custom_template.js b/src/public/static/ext/diyview/js/custom_template.js index e0dab71f3..818a3ce41 100644 --- a/src/public/static/ext/diyview/js/custom_template.js +++ b/src/public/static/ext/diyview/js/custom_template.js @@ -476,8 +476,6 @@ var vue = new Vue({ // 如果当前编辑的组件不存在了,则选中最后一个 if (parseInt(self.currentIndex) >= self.data.length) self.currentIndex--; - $(".draggable-element[data-index=" + self.currentIndex + "] .edit-attribute .attr-wrap").css("height", ($(window).height() - 135) + "px"); - }, 50); }, @@ -630,7 +628,8 @@ function fullScreenSize(isFull) { }); $('.loading-layer').css('height', (commonHeight - 70) + "px"); // 70px是头部高度 $(".component-list nav").css("height", (commonHeight + 20 - 55) + "px");// 20px是自定义模板区域上内边距,55px是标准/第三方组件tab切换高度 - $(".edit-attribute .attr-wrap").css("height", (commonHeight - 1) + "px");// 1px是上边框 + // 设置属性编辑器的最大高度,与预览容器保持一致 + $(".edit-attribute .attr-wrap").css("max-height", (commonHeight) + "px"); $(".preview-block").css("min-height", (commonHeight - 104) + "px"); // 公式:高度 - 自定义模板区域上内边距(20px) - 自定义模板区域下外编辑(20px)- 自定义模板头部(64px) } diff --git a/src/public/static/js/Sortable.min.js b/src/public/static/js/Sortable.min.js new file mode 100644 index 000000000..17bb16c73 --- /dev/null +++ b/src/public/static/js/Sortable.min.js @@ -0,0 +1,2 @@ +/*! Sortable 1.15.0 - MIT | git://github.com/SortableJS/Sortable.git */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function M(o){for(var t=1;tt.length)&&(e=t.length);for(var n=0,o=new Array(e);n"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function N(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&p(t,e)||o&&t===n)return t}while(t!==n&&(t=(i=t).host&&i!==document&&i.host.nodeType?i.host:i.parentNode))}var i;return null}var g,m=/\s+/g;function I(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(m," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(m," ")))}function P(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function v(t,e){var n="";if("string"==typeof t)n=t;else do{var o=P(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function b(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[j]._onDragOver(o)}}var i,r,a}function Yt(t){q&&q.parentNode[j]._isOutsideThisEl(t.target)}function Bt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[j]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return It(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==Bt.supportPointer&&"PointerEvent"in window&&!u,emptyInsertThreshold:5};for(n in K.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in Pt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&Mt,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?h(t,"pointerdown",this._onTapStart):(h(t,"mousedown",this._onTapStart),h(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(h(t,"dragover",this),h(t,"dragenter",this)),Et.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,x())}function Ft(t,e,n,o,i,r,a,l){var s,c,u=t[j],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||k(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function jt(t){t.draggable=!1}function Ht(){Ct=!1}function Lt(t){return setTimeout(t,0)}function Kt(t){return clearTimeout(t)}Bt.prototype={constructor:Bt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(gt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,q):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){Tt.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&Tt.push(o)}}(o),!q&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=N(l,t.draggable,o,!1))&&l.animated||J===l)){if(nt=B(l),it=B(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return U({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),z("filter",n,{evt:e}),void(i&&e.cancelable&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=N(s,t.trim(),o,!1))return U({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),z("filter",n,{evt:e}),!0}))return void(i&&e.cancelable&&e.preventDefault());t.handle&&!N(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!q&&n.parentNode===r&&(o=k(n),$=r,V=(q=n).parentNode,Q=q.nextSibling,J=n,at=a.group,st={target:Bt.dragged=q,clientX:(e||t).clientX,clientY:(e||t).clientY},ht=st.clientX-o.left,ft=st.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,q.style["will-change"]="all",o=function(){z("delayEnded",i,{evt:t}),Bt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!s&&i.nativeDraggable&&(q.draggable=!0),i._triggerDragStart(t,e),U({sortable:i,name:"choose",originalEvent:t}),I(q,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){b(q,t.trim(),jt)}),h(l,"dragover",Xt),h(l,"mousemove",Xt),h(l,"touchmove",Xt),h(l,"mouseup",i._onDrop),h(l,"touchend",i._onDrop),h(l,"touchcancel",i._onDrop),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,q.draggable=!0),z("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():Bt.eventCanceled?this._onDrop():(h(l,"mouseup",i._disableDelayedDrag),h(l,"touchend",i._disableDelayedDrag),h(l,"touchcancel",i._disableDelayedDrag),h(l,"mousemove",i._delayedDragTouchMoveHandler),h(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&h(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){q&&jt(q),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;f(t,"mouseup",this._disableDelayedDrag),f(t,"touchend",this._disableDelayedDrag),f(t,"touchcancel",this._disableDelayedDrag),f(t,"mousemove",this._delayedDragTouchMoveHandler),f(t,"touchmove",this._delayedDragTouchMoveHandler),f(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?h(document,"pointermove",this._onTouchMove):h(document,e?"touchmove":"mousemove",this._onTouchMove):(h(q,"dragend",this),h($,"dragstart",this._onDragStart));try{document.selection?Lt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;yt=!1,$&&q?(z("dragStarted",this,{evt:e}),this.nativeDraggable&&h(document,"dragover",Yt),n=this.options,t||I(q,n.dragClass,!1),I(q,n.ghostClass,!0),Bt.active=this,t&&this._appendGhost(),U({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(ct){this._lastX=ct.clientX,this._lastY=ct.clientY,kt();for(var t=document.elementFromPoint(ct.clientX,ct.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(ct.clientX,ct.clientY))!==e;)e=t;if(q.parentNode[j]._isOutsideThisEl(t),e)do{if(e[j])if(e[j]._onDragOver({clientX:ct.clientX,clientY:ct.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=(t=e).parentNode);Rt()}},_onTouchMove:function(t){if(st){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=Z&&v(Z,!0),a=Z&&r&&r.a,l=Z&&r&&r.d,e=Ot&&bt&&E(bt),a=(i.clientX-st.clientX+o.x)/(a||1)+(e?e[0]-_t[0]:0)/(a||1),l=(i.clientY-st.clientY+o.y)/(l||1)+(e?e[1]-_t[1]:0)/(l||1);if(!Bt.active&&!yt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))n.right+10||t.clientX<=n.right&&t.clientY>n.bottom&&t.clientX>=n.left:t.clientX>n.right&&t.clientY>n.top||t.clientX<=n.right&&t.clientY>n.bottom+10}(n,r,this)&&!g.animated){if(g===q)return O(!1);if((l=g&&a===n.target?g:l)&&(w=k(l)),!1!==Ft($,a,q,o,l,w,n,!!l))return x(),g&&g.nextSibling?a.insertBefore(q,g.nextSibling):a.appendChild(q),V=a,A(),O(!0)}else if(g&&function(t,e,n){n=k(X(n.el,0,n.options,!0));return e?t.clientX