feat: 实现社区激励金(公户)系统,经济闭环
- 新增 CommunityFund / FundLog 模型(公户余额单行表 + 6种流水类型) - 新增 FundRepo 仓储(GetBalance / IncrBalance / CreateLog / ListLogs / WithTx) - 改造 EnergyService 五个方法:Energize 税收入公户、DeductOnRename/DeductOnDeletePost 入公户、RefundOnRenameReject 从公户支出、AdminAdjust 拆分为 admin_transfer/system_operation - 新增 ErrInsufficientFund 错误哨兵 - 管理后台新增公户余额展示 + 公户流水页面 + 域能调整资金来源选择 - 新增 energy.css / energy.js 前端交互 - 侧边栏新增公户流水入口
This commit is contained in:
@ -27,10 +27,15 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("连接数据库失败: %v", err)
|
log.Fatalf("连接数据库失败: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}); err != nil {
|
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}); err != nil {
|
||||||
log.Fatalf("数据库迁移失败: %v", err)
|
log.Fatalf("数据库迁移失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 初始化公户行(id=1, balance=0,如已存在则忽略)
|
||||||
|
if err := db.FirstOrCreate(&model.CommunityFund{ID: 1, Balance: 0}).Error; err != nil {
|
||||||
|
log.Fatalf("初始化公户失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// 邮箱唯一索引迁移:从全表唯一改为部分唯一(仅 deleted_at IS NULL 的行)
|
// 邮箱唯一索引迁移:从全表唯一改为部分唯一(仅 deleted_at IS NULL 的行)
|
||||||
// 支持软删除用户邮箱复用
|
// 支持软删除用户邮箱复用
|
||||||
migrations := []string{
|
migrations := []string{
|
||||||
|
|||||||
@ -52,6 +52,7 @@ var (
|
|||||||
ErrInsufficientEnergy = errors.New("域能不足,可通过每日签到或创作被赋能获取")
|
ErrInsufficientEnergy = errors.New("域能不足,可通过每日签到或创作被赋能获取")
|
||||||
ErrEnergizeLimitReached = errors.New("对该文章赋能已达上限")
|
ErrEnergizeLimitReached = errors.New("对该文章赋能已达上限")
|
||||||
ErrCreatePostNoExp = errors.New("经验不足,Lv1 解锁投稿")
|
ErrCreatePostNoExp = errors.New("经验不足,Lv1 解锁投稿")
|
||||||
|
ErrInsufficientFund = errors.New("公户余额不足,无法执行操作")
|
||||||
|
|
||||||
// 评论相关
|
// 评论相关
|
||||||
ErrCommentNotFound = errors.New("评论不存在")
|
ErrCommentNotFound = errors.New("评论不存在")
|
||||||
|
|||||||
@ -10,10 +10,12 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// adminEnergyUseCase AdminEnergyController 对 EnergyService 的最小依赖(ISP:2 个方法)
|
// adminEnergyUseCase AdminEnergyController 对 EnergyService 的最小依赖(ISP:4 个方法)
|
||||||
type adminEnergyUseCase interface {
|
type adminEnergyUseCase interface {
|
||||||
AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string) error
|
AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string, mode string) error
|
||||||
GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error)
|
GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error)
|
||||||
|
GetFundBalance() (int, error)
|
||||||
|
GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminEnergyController 后台域能管理控制器
|
// AdminEnergyController 后台域能管理控制器
|
||||||
@ -26,10 +28,18 @@ func NewAdminEnergyController(energySvc adminEnergyUseCase) *AdminEnergyControll
|
|||||||
return &AdminEnergyController{energySvc: energySvc}
|
return &AdminEnergyController{energySvc: energySvc}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnergyPage 域能管理页面
|
// EnergyPage 域能管理页面(公户余额 + 用户域能调整)
|
||||||
func (ac *AdminEnergyController) EnergyPage(c *gin.Context) {
|
func (ac *AdminEnergyController) EnergyPage(c *gin.Context) {
|
||||||
|
balance := 0
|
||||||
|
if b, err := ac.energySvc.GetFundBalance(); err == nil {
|
||||||
|
balance = b
|
||||||
|
}
|
||||||
c.HTML(http.StatusOK, "admin/energy/index.html", common.BuildAdminPageData(c, gin.H{
|
c.HTML(http.StatusOK, "admin/energy/index.html", common.BuildAdminPageData(c, gin.H{
|
||||||
"Title": "域能管理",
|
"Title": "域能管理",
|
||||||
|
"ExtraCSS": "/admin/static/css/energy.css",
|
||||||
|
"ExtraJS": "/admin/static/js/energy.js",
|
||||||
|
"FundBalance": balance,
|
||||||
|
"FundBalanceDisplay": float64(balance) / 10,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,6 +47,19 @@ func (ac *AdminEnergyController) EnergyPage(c *gin.Context) {
|
|||||||
func (ac *AdminEnergyController) EnergyLogPage(c *gin.Context) {
|
func (ac *AdminEnergyController) EnergyLogPage(c *gin.Context) {
|
||||||
c.HTML(http.StatusOK, "admin/energy/logs.html", common.BuildAdminPageData(c, gin.H{
|
c.HTML(http.StatusOK, "admin/energy/logs.html", common.BuildAdminPageData(c, gin.H{
|
||||||
"Title": "域能日志",
|
"Title": "域能日志",
|
||||||
|
"ExtraCSS": "/admin/static/css/energy.css",
|
||||||
|
"ExtraJS": "/admin/static/js/energy.js",
|
||||||
|
"LogTypes": model.EnergyLogDisplayNames,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FundLogPage 公户流水页面
|
||||||
|
func (ac *AdminEnergyController) FundLogPage(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusOK, "admin/energy/fund_logs.html", common.BuildAdminPageData(c, gin.H{
|
||||||
|
"Title": "公户流水",
|
||||||
|
"ExtraCSS": "/admin/static/css/energy.css",
|
||||||
|
"ExtraJS": "/admin/static/js/energy.js",
|
||||||
|
"LogTypes": model.FundLogDisplayNames,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,13 +75,32 @@ func (ac *AdminEnergyController) AdjustEnergy(c *gin.Context) {
|
|||||||
UserIDs []uint `json:"user_ids" binding:"required,min=1"`
|
UserIDs []uint `json:"user_ids" binding:"required,min=1"`
|
||||||
Amount int `json:"amount" binding:"required"`
|
Amount int `json:"amount" binding:"required"`
|
||||||
Description string `json:"description" binding:"required,min=1,max=500"`
|
Description string `json:"description" binding:"required,min=1,max=500"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
common.Error(c, http.StatusBadRequest, "参数错误:需要 user_ids、amount 和 description")
|
common.Error(c, http.StatusBadRequest, "参数错误:需要 user_ids、amount 和 description")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ac.energySvc.AdminAdjust(operatorUID.(uint), req.UserIDs, req.Amount, req.Description); err != nil {
|
// 默认模式为 admin_transfer
|
||||||
|
if req.Mode == "" {
|
||||||
|
req.Mode = model.FundTypeAdminTransfer
|
||||||
|
}
|
||||||
|
|
||||||
|
// system_operation 仅 owner 可用
|
||||||
|
if req.Mode == model.FundTypeSystemOperation {
|
||||||
|
role, _ := c.Get("role")
|
||||||
|
if !model.HasMinRole(role.(string), model.RoleOwner) {
|
||||||
|
common.Error(c, http.StatusForbidden, "仅站长可使用系统操作模式")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ac.energySvc.AdminAdjust(operatorUID.(uint), req.UserIDs, req.Amount, req.Description, req.Mode); err != nil {
|
||||||
|
if err == common.ErrInsufficientFund {
|
||||||
|
common.Error(c, http.StatusBadRequest, "公户余额不足,无法执行操作")
|
||||||
|
return
|
||||||
|
}
|
||||||
common.Error(c, http.StatusInternalServerError, "调整失败")
|
common.Error(c, http.StatusInternalServerError, "调整失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -101,3 +143,51 @@ func (ac *AdminEnergyController) ListEnergyLogs(c *gin.Context) {
|
|||||||
"total_pages": common.PageCount(total, pageSize),
|
"total_pages": common.PageCount(total, pageSize),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetFundBalance 获取公户余额
|
||||||
|
func (ac *AdminEnergyController) GetFundBalance(c *gin.Context) {
|
||||||
|
balance, err := ac.energySvc.GetFundBalance()
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"balance": balance,
|
||||||
|
"balance_display": float64(balance) / 10,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFundLogs 查询公户流水
|
||||||
|
func (ac *AdminEnergyController) ListFundLogs(c *gin.Context) {
|
||||||
|
logType := c.Query("type")
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
|
logs, total, err := ac.energySvc.GetFundLogs(logType, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type logView struct {
|
||||||
|
model.FundLog
|
||||||
|
AmountDisplay float64 `json:"amount_display"`
|
||||||
|
TypeName string `json:"type_name"`
|
||||||
|
}
|
||||||
|
views := make([]logView, len(logs))
|
||||||
|
for i, l := range logs {
|
||||||
|
views[i] = logView{
|
||||||
|
FundLog: l,
|
||||||
|
AmountDisplay: float64(l.Amount) / 10,
|
||||||
|
TypeName: model.FundLogDisplayNames[l.Type],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"items": views,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": pageSize,
|
||||||
|
"total_pages": common.PageCount(total, pageSize),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -86,10 +86,12 @@ type energyRenameHandler interface {
|
|||||||
DeductOnRename(userID uint) error
|
DeductOnRename(userID uint) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// energyAdminUseCase AdminEnergyController 对 EnergyService 的依赖(ISP:2 个方法)
|
// energyAdminUseCase AdminEnergyController 对 EnergyService 的依赖(ISP:4 个方法)
|
||||||
type energyAdminUseCase interface {
|
type energyAdminUseCase interface {
|
||||||
AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string) error
|
AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string, mode string) error
|
||||||
GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error)
|
GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error)
|
||||||
|
GetFundBalance() (int, error)
|
||||||
|
GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// commentUseCase CommentController 对 CommentService 的最小依赖(ISP:6 个方法)
|
// commentUseCase CommentController 对 CommentService 的最小依赖(ISP:6 个方法)
|
||||||
|
|||||||
10
internal/model/community_fund.go
Normal file
10
internal/model/community_fund.go
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// CommunityFund 公户余额(单行表,id=1)
|
||||||
|
type CommunityFund struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Balance int `gorm:"not null;default:0" json:"balance"` // 余额(×10,允许负数)
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
35
internal/model/fund_log.go
Normal file
35
internal/model/fund_log.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// 公户流水类型常量
|
||||||
|
const (
|
||||||
|
FundTypeEnergizeTax = "energize_tax" // 赋能税收(入公户)
|
||||||
|
FundTypeRenameDeduction = "rename_deduction" // 改名扣费(入公户)
|
||||||
|
FundTypeRenameRefund = "rename_refund" // 改名退款(出公户)
|
||||||
|
FundTypeDeletePost = "delete_post_deduction" // 删稿扣费(入公户)
|
||||||
|
FundTypeAdminTransfer = "admin_transfer" // 管理员转账(出公户,受余额约束)
|
||||||
|
FundTypeSystemOperation = "system_operation" // 站长直调(双向,不受余额约束)
|
||||||
|
)
|
||||||
|
|
||||||
|
// FundLog 公户流水记录
|
||||||
|
type FundLog struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Amount int `gorm:"not null" json:"amount"` // +入公户 / –出公户(×10)
|
||||||
|
Type string `gorm:"type:varchar(50);index;not null" json:"type"` // 日志类型
|
||||||
|
RelatedType string `gorm:"type:varchar(50);default:''" json:"related_type,omitempty"`
|
||||||
|
RelatedID uint `gorm:"default:0" json:"related_id,omitempty"`
|
||||||
|
OperatorUID *uint `gorm:"default:null" json:"operator_uid,omitempty"`
|
||||||
|
Description string `gorm:"type:text;not null" json:"description"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FundLogDisplayNames 公户流水类型 → 中文名
|
||||||
|
var FundLogDisplayNames = map[string]string{
|
||||||
|
FundTypeEnergizeTax: "赋能税收",
|
||||||
|
FundTypeRenameDeduction: "改名扣费",
|
||||||
|
FundTypeRenameRefund: "改名退款",
|
||||||
|
FundTypeDeletePost: "删稿扣费",
|
||||||
|
FundTypeAdminTransfer: "管理转出",
|
||||||
|
FundTypeSystemOperation: "系统操作",
|
||||||
|
}
|
||||||
60
internal/repository/fund_repo.go
Normal file
60
internal/repository/fund_repo.go
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
"metazone.cc/metalab/internal/service"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FundRepo 公户数据访问
|
||||||
|
type FundRepo struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFundRepo 构造函数
|
||||||
|
func NewFundRepo(db *gorm.DB) *FundRepo {
|
||||||
|
return &FundRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTx 基于给定事务连接创建新的 FundRepo
|
||||||
|
func (r *FundRepo) WithTx(tx *gorm.DB) service.FundStore {
|
||||||
|
return &FundRepo{db: tx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBalance 查询公户余额
|
||||||
|
func (r *FundRepo) GetBalance() (int, error) {
|
||||||
|
var fund model.CommunityFund
|
||||||
|
err := r.db.First(&fund, 1).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return fund.Balance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrBalance 原子增减公户余额
|
||||||
|
func (r *FundRepo) IncrBalance(amount int) error {
|
||||||
|
return r.db.Model(&model.CommunityFund{}).
|
||||||
|
Where("id = 1").
|
||||||
|
UpdateColumn("balance", gorm.Expr("balance + ?", amount)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateLog 创建公户流水
|
||||||
|
func (r *FundRepo) CreateLog(log *model.FundLog) error {
|
||||||
|
return r.db.Create(log).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLogs 分页查询公户流水
|
||||||
|
func (r *FundRepo) ListLogs(logType string, offset, limit int) ([]model.FundLog, int64, error) {
|
||||||
|
query := r.db.Model(&model.FundLog{})
|
||||||
|
if logType != "" {
|
||||||
|
query = query.Where("type = ?", logType)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var logs []model.FundLog
|
||||||
|
err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error
|
||||||
|
return logs, total, err
|
||||||
|
}
|
||||||
@ -30,6 +30,7 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
middleware.RequirePageRole(model.RoleOwner))
|
middleware.RequirePageRole(model.RoleOwner))
|
||||||
adminPages.GET("/energy", d.adminEnergyCtrl.EnergyPage)
|
adminPages.GET("/energy", d.adminEnergyCtrl.EnergyPage)
|
||||||
adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage)
|
adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage)
|
||||||
|
adminPages.GET("/energy/fund-logs", d.adminEnergyCtrl.FundLogPage)
|
||||||
adminPages.GET("/comments", d.adminCommentCtrl.CommentsPage)
|
adminPages.GET("/comments", d.adminCommentCtrl.CommentsPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,16 +76,17 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
adminPostAPI.POST("/:id/restore", d.adminPostCtrl.Restore)
|
adminPostAPI.POST("/:id/restore", d.adminPostCtrl.Restore)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 域能管理 API(admin+,调整需要 owner) ---
|
// --- 域能管理 API(admin+,system_operation 仅 owner) ---
|
||||||
adminEnergyAPI := r.Group("/api/admin/energy")
|
adminEnergyAPI := r.Group("/api/admin/energy")
|
||||||
adminEnergyAPI.Use(d.authMdw.Required())
|
adminEnergyAPI.Use(d.authMdw.Required())
|
||||||
adminEnergyAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
|
adminEnergyAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
|
||||||
adminEnergyAPI.Use(d.authMdw.BannedWriteGuard())
|
adminEnergyAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
adminEnergyAPI.Use(middleware.CSRF(cfg))
|
adminEnergyAPI.Use(middleware.CSRF(cfg))
|
||||||
{
|
{
|
||||||
adminEnergyAPI.POST("/adjust", d.adminEnergyCtrl.AdjustEnergy,
|
adminEnergyAPI.POST("/adjust", d.adminEnergyCtrl.AdjustEnergy)
|
||||||
middleware.RequireMinRole(model.RoleOwner))
|
|
||||||
adminEnergyAPI.GET("/logs", d.adminEnergyCtrl.ListEnergyLogs)
|
adminEnergyAPI.GET("/logs", d.adminEnergyCtrl.ListEnergyLogs)
|
||||||
|
adminEnergyAPI.GET("/fund-balance", d.adminEnergyCtrl.GetFundBalance)
|
||||||
|
adminEnergyAPI.GET("/fund-logs", d.adminEnergyCtrl.ListFundLogs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 评论管理 API(admin+) ---
|
// --- 评论管理 API(admin+) ---
|
||||||
|
|||||||
@ -21,7 +21,9 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
|||||||
|
|
||||||
// 域能系统
|
// 域能系统
|
||||||
energyRepo := repository.NewEnergyRepo(db)
|
energyRepo := repository.NewEnergyRepo(db)
|
||||||
|
fundRepo := repository.NewFundRepo(db)
|
||||||
energyService := service.NewEnergyService(db, energyRepo, nil, notificationService)
|
energyService := service.NewEnergyService(db, energyRepo, nil, notificationService)
|
||||||
|
energyService.SetFundRepo(fundRepo)
|
||||||
|
|
||||||
// 等级/签到/任务系统(链式注入域能服务)
|
// 等级/签到/任务系统(链式注入域能服务)
|
||||||
_, levelSvc, levelCtrl := buildDepsLevel(db, notificationService, authMdw)
|
_, levelSvc, levelCtrl := buildDepsLevel(db, notificationService, authMdw)
|
||||||
|
|||||||
@ -19,6 +19,7 @@ type expAdder interface {
|
|||||||
type EnergyService struct {
|
type EnergyService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
repo EnergyStore
|
repo EnergyStore
|
||||||
|
fundRepo FundStore
|
||||||
expSvc expAdder
|
expSvc expAdder
|
||||||
notifSvc energyNotifier
|
notifSvc energyNotifier
|
||||||
}
|
}
|
||||||
@ -28,6 +29,16 @@ func NewEnergyService(db *gorm.DB, repo EnergyStore, expSvc expAdder, notifSvc e
|
|||||||
return &EnergyService{db: db, repo: repo, expSvc: expSvc, notifSvc: notifSvc}
|
return &EnergyService{db: db, repo: repo, expSvc: expSvc, notifSvc: notifSvc}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetExpAdder 链式注入经验服务(用于赋能获得经验)
|
||||||
|
func (s *EnergyService) SetExpAdder(svc expAdder) {
|
||||||
|
s.expSvc = svc
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFundRepo 链式注入公户仓储
|
||||||
|
func (s *EnergyService) SetFundRepo(repo FundStore) {
|
||||||
|
s.fundRepo = repo
|
||||||
|
}
|
||||||
|
|
||||||
// EnergyInfo 域能信息(含每日赋能经验状态)
|
// EnergyInfo 域能信息(含每日赋能经验状态)
|
||||||
type EnergyInfo struct {
|
type EnergyInfo struct {
|
||||||
Energy int // 域能余额(乘10存储)
|
Energy int // 域能余额(乘10存储)
|
||||||
@ -36,11 +47,6 @@ type EnergyInfo struct {
|
|||||||
DailyExpCapReached bool // 是否已达上限
|
DailyExpCapReached bool // 是否已达上限
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetExpAdder 链式注入经验服务(用于赋能获得经验)
|
|
||||||
func (s *EnergyService) SetExpAdder(svc expAdder) {
|
|
||||||
s.expSvc = svc
|
|
||||||
}
|
|
||||||
|
|
||||||
// 域能相关常量(乘10存储,DB值)
|
// 域能相关常量(乘10存储,DB值)
|
||||||
const (
|
const (
|
||||||
EnergyCheckIn = 10 // +1.0 域能
|
EnergyCheckIn = 10 // +1.0 域能
|
||||||
@ -69,6 +75,10 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int
|
|||||||
|
|
||||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
txRepo := s.repo.WithTx(tx)
|
txRepo := s.repo.WithTx(tx)
|
||||||
|
var txFundRepo FundStore
|
||||||
|
if s.fundRepo != nil {
|
||||||
|
txFundRepo = s.fundRepo.WithTx(tx)
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 检查帖子存在,获取作者
|
// 1. 检查帖子存在,获取作者
|
||||||
post, err := txRepo.FindPostByID(postID)
|
post, err := txRepo.FindPostByID(postID)
|
||||||
@ -156,7 +166,28 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int
|
|||||||
return fmt.Errorf("创建被赋能者流水失败: %w", err)
|
return fmt.Errorf("创建被赋能者流水失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. 计算经验值(溢出截断)—— 在事务内读取以保证一致性
|
// 10. 赋能税收入公户(差价 = amount - receivedAmount)
|
||||||
|
if txFundRepo != nil {
|
||||||
|
tax := amount - receivedAmount
|
||||||
|
if tax > 0 {
|
||||||
|
if err := txFundRepo.IncrBalance(tax); err != nil {
|
||||||
|
return fmt.Errorf("公户入账失败: %w", err)
|
||||||
|
}
|
||||||
|
taxDisplay := float64(tax) / 10
|
||||||
|
fundLog := &model.FundLog{
|
||||||
|
Amount: tax,
|
||||||
|
Type: model.FundTypeEnergizeTax,
|
||||||
|
RelatedType: "post",
|
||||||
|
RelatedID: postID,
|
||||||
|
Description: fmt.Sprintf("赋能文章 #%d 税收 %.1f 域能,赋能者 uid=%d", postID, taxDisplay, energizerID),
|
||||||
|
}
|
||||||
|
if err := txFundRepo.CreateLog(fundLog); err != nil {
|
||||||
|
return fmt.Errorf("创建公户流水失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. 计算经验值(溢出截断)—— 在事务内读取以保证一致性
|
||||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||||
now := time.Now().In(loc)
|
now := time.Now().In(loc)
|
||||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||||
@ -188,9 +219,7 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 11. 事务提交后,在事务外增加经验 + 更新每日汇总
|
// 12. 事务提交后,在事务外增加经验 + 更新每日汇总
|
||||||
// 必须在事务外执行:LevelService.AddExp 也操作 users 表,
|
|
||||||
// 若在事务内调用会造成连接池不同连接间行锁死锁。
|
|
||||||
if needExpUpdate {
|
if needExpUpdate {
|
||||||
if s.expSvc != nil {
|
if s.expSvc != nil {
|
||||||
if _, _, err := s.expSvc.AddExp(energizerID, expGained); err != nil {
|
if _, _, err := s.expSvc.AddExp(energizerID, expGained); err != nil {
|
||||||
@ -226,9 +255,13 @@ func (s *EnergyService) CheckInEnergy(userID uint) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeductOnRename 改名扣域能(首次改名免费,不调用此方法)
|
// DeductOnRename 改名扣域能(首次改名免费,不调用此方法)
|
||||||
|
// 事务包裹:扣用户域能 + 入公户 + 写流水
|
||||||
func (s *EnergyService) DeductOnRename(userID uint) error {
|
func (s *EnergyService) DeductOnRename(userID uint) error {
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
txRepo := s.repo.WithTx(tx)
|
||||||
|
|
||||||
// 检查域能余额(非 owner 需 ≥ 0)
|
// 检查域能余额(非 owner 需 ≥ 0)
|
||||||
user, err := s.repo.FindUserByID(userID)
|
user, err := txRepo.FindUserByID(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("查询用户失败: %w", err)
|
return fmt.Errorf("查询用户失败: %w", err)
|
||||||
}
|
}
|
||||||
@ -236,22 +269,62 @@ func (s *EnergyService) DeductOnRename(userID uint) error {
|
|||||||
return common.ErrInsufficientEnergy
|
return common.ErrInsufficientEnergy
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.repo.AddEnergy(userID, EnergyRename); err != nil {
|
if err := txRepo.AddEnergy(userID, EnergyRename); err != nil {
|
||||||
return fmt.Errorf("扣减改名域能失败: %w", err)
|
return fmt.Errorf("扣减改名域能失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 公户入账
|
||||||
|
if s.fundRepo != nil {
|
||||||
|
txFundRepo := s.fundRepo.WithTx(tx)
|
||||||
|
if err := txFundRepo.IncrBalance(-EnergyRename); err != nil { // EnergyRename = -60,入公户 +60
|
||||||
|
return fmt.Errorf("公户入账失败: %w", err)
|
||||||
|
}
|
||||||
|
fundLog := &model.FundLog{
|
||||||
|
Amount: -EnergyRename,
|
||||||
|
Type: model.FundTypeRenameDeduction,
|
||||||
|
RelatedType: "user",
|
||||||
|
RelatedID: userID,
|
||||||
|
Description: fmt.Sprintf("用户 uid=%d 改名消耗 6.0 域能", userID),
|
||||||
|
}
|
||||||
|
if err := txFundRepo.CreateLog(fundLog); err != nil {
|
||||||
|
return fmt.Errorf("创建公户流水失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log := &model.EnergyLog{
|
log := &model.EnergyLog{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Amount: EnergyRename,
|
Amount: EnergyRename,
|
||||||
Type: model.EnergyTypeRename,
|
Type: model.EnergyTypeRename,
|
||||||
Description: "改名消耗 6.0 域能",
|
Description: "改名消耗 6.0 域能",
|
||||||
}
|
}
|
||||||
return s.repo.CreateEnergyLog(log)
|
return txRepo.CreateEnergyLog(log)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefundOnRenameReject 改名审核被拒,返还域能
|
// RefundOnRenameReject 改名审核被拒,从公户返还域能
|
||||||
func (s *EnergyService) RefundOnRenameReject(userID uint) error {
|
func (s *EnergyService) RefundOnRenameReject(userID uint) error {
|
||||||
if err := s.repo.AddEnergy(userID, EnergyRenameRefund); err != nil {
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
txRepo := s.repo.WithTx(tx)
|
||||||
|
|
||||||
|
// 从公户支出(允许透支)
|
||||||
|
if s.fundRepo != nil {
|
||||||
|
txFundRepo := s.fundRepo.WithTx(tx)
|
||||||
|
if err := txFundRepo.IncrBalance(-EnergyRenameRefund); err != nil {
|
||||||
|
return fmt.Errorf("公户出账失败: %w", err)
|
||||||
|
}
|
||||||
|
fundLog := &model.FundLog{
|
||||||
|
Amount: -EnergyRenameRefund,
|
||||||
|
Type: model.FundTypeRenameRefund,
|
||||||
|
RelatedType: "user",
|
||||||
|
RelatedID: userID,
|
||||||
|
Description: fmt.Sprintf("用户 uid=%d 改名审核被拒,返还 6.0 域能", userID),
|
||||||
|
}
|
||||||
|
if err := txFundRepo.CreateLog(fundLog); err != nil {
|
||||||
|
return fmt.Errorf("创建公户流水失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := txRepo.AddEnergy(userID, EnergyRenameRefund); err != nil {
|
||||||
return fmt.Errorf("返还改名域能失败: %w", err)
|
return fmt.Errorf("返还改名域能失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -261,11 +334,12 @@ func (s *EnergyService) RefundOnRenameReject(userID uint) error {
|
|||||||
Type: model.EnergyTypeRenameRefund,
|
Type: model.EnergyTypeRenameRefund,
|
||||||
Description: "改名审核被拒,返还 6.0 域能",
|
Description: "改名审核被拒,返还 6.0 域能",
|
||||||
}
|
}
|
||||||
return s.repo.CreateEnergyLog(log)
|
return txRepo.CreateEnergyLog(log)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeductOnDeletePost 删稿扣域能(扣文章作者的域能,无视负数)
|
// DeductOnDeletePost 删稿扣域能(扣文章作者的域能,无视负数)
|
||||||
// 事务包裹:确保扣域能 + 写流水原子性
|
// 事务包裹:扣用户域能 + 入公户 + 写流水
|
||||||
func (s *EnergyService) DeductOnDeletePost(authorUserID uint, postID uint) error {
|
func (s *EnergyService) DeductOnDeletePost(authorUserID uint, postID uint) error {
|
||||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
txRepo := s.repo.WithTx(tx)
|
txRepo := s.repo.WithTx(tx)
|
||||||
@ -274,6 +348,24 @@ func (s *EnergyService) DeductOnDeletePost(authorUserID uint, postID uint) error
|
|||||||
return fmt.Errorf("扣减删稿域能失败: %w", err)
|
return fmt.Errorf("扣减删稿域能失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 公户入账
|
||||||
|
if s.fundRepo != nil {
|
||||||
|
txFundRepo := s.fundRepo.WithTx(tx)
|
||||||
|
if err := txFundRepo.IncrBalance(-EnergyDeletePost); err != nil { // EnergyDeletePost = -20,入公户 +20
|
||||||
|
return fmt.Errorf("公户入账失败: %w", err)
|
||||||
|
}
|
||||||
|
fundLog := &model.FundLog{
|
||||||
|
Amount: -EnergyDeletePost,
|
||||||
|
Type: model.FundTypeDeletePost,
|
||||||
|
RelatedType: "post",
|
||||||
|
RelatedID: postID,
|
||||||
|
Description: fmt.Sprintf("用户 uid=%d 删除文章 #%d 消耗 2.0 域能", authorUserID, postID),
|
||||||
|
}
|
||||||
|
if err := txFundRepo.CreateLog(fundLog); err != nil {
|
||||||
|
return fmt.Errorf("创建公户流水失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log := &model.EnergyLog{
|
log := &model.EnergyLog{
|
||||||
UserID: authorUserID,
|
UserID: authorUserID,
|
||||||
Amount: EnergyDeletePost,
|
Amount: EnergyDeletePost,
|
||||||
@ -287,25 +379,87 @@ func (s *EnergyService) DeductOnDeletePost(authorUserID uint, postID uint) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AdminAdjust 后台调整用户域能
|
// AdminAdjust 后台调整用户域能
|
||||||
func (s *EnergyService) AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string) error {
|
// mode: "admin_transfer"(受公户余额约束)| "system_operation"(不受约束)
|
||||||
|
func (s *EnergyService) AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string, mode string) error {
|
||||||
|
if mode == "" {
|
||||||
|
mode = model.FundTypeAdminTransfer
|
||||||
|
}
|
||||||
|
|
||||||
|
// admin_transfer 出账时检查公户余额
|
||||||
|
if mode == model.FundTypeAdminTransfer && amount > 0 && s.fundRepo != nil {
|
||||||
|
balance, err := s.fundRepo.GetBalance()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("查询公户余额失败: %w", err)
|
||||||
|
}
|
||||||
|
if balance < amount {
|
||||||
|
return common.ErrInsufficientFund
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
txRepo := s.repo.WithTx(tx)
|
||||||
|
var txFundRepo FundStore
|
||||||
|
if s.fundRepo != nil {
|
||||||
|
txFundRepo = s.fundRepo.WithTx(tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公户余额调整(给用户 energy=+amount → 公户 -amount,扣用户 energy=-amount → 公户 +amount)
|
||||||
|
if txFundRepo != nil {
|
||||||
|
if err := txFundRepo.IncrBalance(-amount); err != nil {
|
||||||
|
return fmt.Errorf("公户余额调整失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, uid := range userIDs {
|
for _, uid := range userIDs {
|
||||||
if err := s.repo.AddEnergy(uid, amount); err != nil {
|
if err := txRepo.AddEnergy(uid, amount); err != nil {
|
||||||
return fmt.Errorf("调整用户 %d 域能失败: %w", uid, err)
|
return fmt.Errorf("调整用户 %d 域能失败: %w", uid, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
opUID := operatorUID
|
opUID := operatorUID
|
||||||
log := &model.EnergyLog{
|
energyLog := &model.EnergyLog{
|
||||||
UserID: uid,
|
UserID: uid,
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
Type: model.EnergyTypeAdminAdjust,
|
Type: model.EnergyTypeAdminAdjust,
|
||||||
Description: description,
|
Description: description,
|
||||||
OperatorUID: &opUID,
|
OperatorUID: &opUID,
|
||||||
}
|
}
|
||||||
if err := s.repo.CreateEnergyLog(log); err != nil {
|
if err := txRepo.CreateEnergyLog(energyLog); err != nil {
|
||||||
return fmt.Errorf("创建调整流水失败: %w", err)
|
return fmt.Errorf("创建调整流水失败: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 公户流水
|
||||||
|
if txFundRepo != nil {
|
||||||
|
amountDisplay := float64(amount) / 10
|
||||||
|
fundLog := &model.FundLog{
|
||||||
|
Amount: -amount,
|
||||||
|
Type: mode,
|
||||||
|
OperatorUID: &operatorUID,
|
||||||
|
Description: fmt.Sprintf("%s:调整用户域能 %.1f,说明:%s", getFundModeName(mode), amountDisplay, description),
|
||||||
|
}
|
||||||
|
if len(userIDs) > 0 {
|
||||||
|
fundLog.RelatedType = "user"
|
||||||
|
fundLog.RelatedID = userIDs[0]
|
||||||
|
}
|
||||||
|
if err := txFundRepo.CreateLog(fundLog); err != nil {
|
||||||
|
return fmt.Errorf("创建公户流水失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFundModeName 返回操作模式中文名
|
||||||
|
func getFundModeName(mode string) string {
|
||||||
|
switch mode {
|
||||||
|
case model.FundTypeAdminTransfer:
|
||||||
|
return "管理转出"
|
||||||
|
case model.FundTypeSystemOperation:
|
||||||
|
return "系统操作"
|
||||||
|
default:
|
||||||
|
return "后台调整"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetEnergyInfo 获取用户域能余额和每日赋能经验状态
|
// GetEnergyInfo 获取用户域能余额和每日赋能经验状态
|
||||||
@ -359,3 +513,28 @@ func (s *EnergyService) GetAdminEnergyLogs(energyType string, page, pageSize int
|
|||||||
p.DefaultPagination()
|
p.DefaultPagination()
|
||||||
return s.repo.ListAllEnergyLogs(energyType, p.Offset(), p.PageSize)
|
return s.repo.ListAllEnergyLogs(energyType, p.Offset(), p.PageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetFundBalance 获取公户余额
|
||||||
|
func (s *EnergyService) GetFundBalance() (int, error) {
|
||||||
|
if s.fundRepo == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return s.fundRepo.GetBalance()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFundLogs 分页查询公户流水
|
||||||
|
func (s *EnergyService) GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error) {
|
||||||
|
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||||
|
p.DefaultPagination()
|
||||||
|
if s.fundRepo == nil {
|
||||||
|
return []model.FundLog{}, 0, nil
|
||||||
|
}
|
||||||
|
logs, total, err := s.fundRepo.ListLogs(logType, p.Offset(), p.PageSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if logs == nil {
|
||||||
|
logs = []model.FundLog{}
|
||||||
|
}
|
||||||
|
return logs, total, nil
|
||||||
|
}
|
||||||
|
|||||||
@ -111,6 +111,15 @@ type EnergyStore interface {
|
|||||||
// energyStore 向后兼容别名
|
// energyStore 向后兼容别名
|
||||||
type energyStore = EnergyStore
|
type energyStore = EnergyStore
|
||||||
|
|
||||||
|
// FundStore 公户仓储接口(ISP)
|
||||||
|
type FundStore interface {
|
||||||
|
GetBalance() (int, error)
|
||||||
|
IncrBalance(amount int) error
|
||||||
|
CreateLog(log *model.FundLog) error
|
||||||
|
ListLogs(logType string, offset, limit int) ([]model.FundLog, int64, error)
|
||||||
|
WithTx(tx *gorm.DB) FundStore
|
||||||
|
}
|
||||||
|
|
||||||
// energyNotifier EnergyService 所需的通知接口(ISP)
|
// energyNotifier EnergyService 所需的通知接口(ISP)
|
||||||
type energyNotifier interface {
|
type energyNotifier interface {
|
||||||
Create(userID uint, notifyType, title, content string, relatedID, commentID *uint) error
|
Create(userID uint, notifyType, title, content string, relatedID, commentID *uint) error
|
||||||
|
|||||||
30
templates/admin/html/energy/fund_logs.html
Normal file
30
templates/admin/html/energy/fund_logs.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{{template "admin/layout/base.html" .}}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>公户流水</h1>
|
||||||
|
<p class="page-desc">查看社区激励金(公户)的全部收支记录</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-bar">
|
||||||
|
<label for="fund-log-type-filter" style="font-size:0.85rem;color:#4a5568;font-weight:500;">类型筛选:</label>
|
||||||
|
<select id="fund-log-type-filter">
|
||||||
|
<option value="">全部类型</option>
|
||||||
|
{{range $type, $name := .LogTypes}}<option value="{{$type}}">{{$name}}</option>{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="log-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>变动</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>说明</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="fund-log-tbody">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div id="fund-log-pagination" class="pagination"></div>
|
||||||
|
{{template "admin/layout/footer.html" .}}
|
||||||
@ -1,2 +1,44 @@
|
|||||||
{{template "admin/layout/base.html" .}}
|
{{template "admin/layout/base.html" .}}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>域能管理</h1>
|
||||||
|
<p class="page-desc">管理公户余额与用户域能调整</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fund-card">
|
||||||
|
<div class="fund-info">
|
||||||
|
<h3>公户余额</h3>
|
||||||
|
<p id="fund-balance" class="fund-balance{{if lt .FundBalance 0}} negative{{end}}">{{printf "%.1f" .FundBalanceDisplay}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="fund-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" width="48" height="48"><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="adjust-form">
|
||||||
|
<h3>调整用户域能</h3>
|
||||||
|
<form id="adjust-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="user_ids">目标用户 UID(多个用英文逗号分隔)</label>
|
||||||
|
<input type="text" id="user_ids" name="user_ids" placeholder="例如:1,2,3" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="amount">调整金额(正数为增加,负数为扣减,×10存储)</label>
|
||||||
|
<input type="number" id="amount" name="amount" placeholder="例如:10 表示 +1.0 域能" required>
|
||||||
|
<div class="form-hint">存储单位 ×10:输入 10 = +1.0 域能,输入 -5 = -0.5 域能</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="adjust-mode">资金来源</label>
|
||||||
|
<select id="adjust-mode" name="mode">
|
||||||
|
<option value="admin_transfer">公户转出(受余额约束)</option>
|
||||||
|
{{if .IsOwner}}<option value="system_operation">系统操作(不受余额约束,仅站长可用)</option>{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description">操作说明</label>
|
||||||
|
<textarea id="description" name="description" placeholder="简要说明调整原因" required maxlength="500"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">确认调整</button>
|
||||||
|
<div id="adjust-message" class="form-message"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
{{template "admin/layout/footer.html" .}}
|
{{template "admin/layout/footer.html" .}}
|
||||||
|
|||||||
@ -1,2 +1,31 @@
|
|||||||
{{template "admin/layout/base.html" .}}
|
{{template "admin/layout/base.html" .}}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>域能日志</h1>
|
||||||
|
<p class="page-desc">查看所有用户的域能流水记录</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-bar">
|
||||||
|
<label for="log-type-filter" style="font-size:0.85rem;color:#4a5568;font-weight:500;">类型筛选:</label>
|
||||||
|
<select id="log-type-filter">
|
||||||
|
<option value="">全部类型</option>
|
||||||
|
{{range $type, $name := .LogTypes}}<option value="{{$type}}">{{$name}}</option>{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="log-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>用户UID</th>
|
||||||
|
<th>变动</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>说明</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="energy-log-tbody">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div id="energy-log-pagination" class="pagination"></div>
|
||||||
{{template "admin/layout/footer.html" .}}
|
{{template "admin/layout/footer.html" .}}
|
||||||
|
|||||||
@ -48,9 +48,13 @@
|
|||||||
域能管理
|
域能管理
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/energy/logs" class="sidebar-item {{if eq .CurrentPath "/admin/energy/logs"}}active{{end}}">
|
<a href="/admin/energy/logs" class="sidebar-item {{if eq .CurrentPath "/admin/energy/logs"}}active{{end}}">
|
||||||
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>
|
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/></svg></span>
|
||||||
域能日志
|
域能日志
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/energy/fund-logs" class="sidebar-item {{if eq .CurrentPath "/admin/energy/fund-logs"}}active{{end}}">
|
||||||
|
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg></span>
|
||||||
|
公户流水
|
||||||
|
</a>
|
||||||
<a href="/admin/comments" class="sidebar-item {{if eq .CurrentPath "/admin/comments"}}active{{end}}">
|
<a href="/admin/comments" class="sidebar-item {{if eq .CurrentPath "/admin/comments"}}active{{end}}">
|
||||||
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>
|
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>
|
||||||
评论管理
|
评论管理
|
||||||
|
|||||||
259
templates/admin/static/css/energy.css
Normal file
259
templates/admin/static/css/energy.css
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
/* 域能管理页面样式 */
|
||||||
|
|
||||||
|
.fund-card {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fund-card .fund-info h3 {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fund-card .fund-info .fund-balance {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fund-card .fund-info .fund-balance.negative {
|
||||||
|
color: #fdd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fund-card .fund-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 调整表单 */
|
||||||
|
.adjust-form {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adjust-form h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #1a202c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group textarea,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid #cbd5e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #2d3748;
|
||||||
|
background: #fff;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group textarea:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #a0aec0;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #667eea;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 0.6rem 1.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #5a67d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 日志表格 */
|
||||||
|
.log-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-table th {
|
||||||
|
background: #f7fafc;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4a5568;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-table td {
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #2d3748;
|
||||||
|
border-bottom: 1px solid #edf2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-table .amount-positive {
|
||||||
|
color: #38a169;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-table .amount-negative {
|
||||||
|
color: #e53e3e;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: #ebf8ff;
|
||||||
|
color: #2b6cb0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 筛选栏 */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar select {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border: 1px solid #cbd5e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #4a5568;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分页 */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button {
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
border: 1px solid #cbd5e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #4a5568;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button:hover:not(:disabled) {
|
||||||
|
background: #f7fafc;
|
||||||
|
border-color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button.active {
|
||||||
|
background: #667eea;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作结果消息 */
|
||||||
|
.form-message {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-message.success {
|
||||||
|
background: #f0fff4;
|
||||||
|
color: #38a169;
|
||||||
|
border: 1px solid #c6f6d5;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-message.error {
|
||||||
|
background: #fff5f5;
|
||||||
|
color: #e53e3e;
|
||||||
|
border: 1px solid #fed7d7;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 日志时间 */
|
||||||
|
.log-time {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #a0aec0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: #a0aec0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
207
templates/admin/static/js/energy.js
Normal file
207
templates/admin/static/js/energy.js
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
// 域能管理页面 JS
|
||||||
|
(function () {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
|
||||||
|
if (path === '/admin/energy') {
|
||||||
|
initEnergyPage();
|
||||||
|
} else if (path === '/admin/energy/logs') {
|
||||||
|
initEnergyLogPage();
|
||||||
|
} else if (path === '/admin/energy/fund-logs') {
|
||||||
|
initFundLogPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- 域能管理首页:公户余额 + 调整表单 -----
|
||||||
|
function initEnergyPage() {
|
||||||
|
var adjustForm = document.getElementById('adjust-form');
|
||||||
|
if (!adjustForm) return;
|
||||||
|
|
||||||
|
adjustForm.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var form = e.target;
|
||||||
|
var submitBtn = form.querySelector('button[type="submit"]');
|
||||||
|
var msgEl = document.getElementById('adjust-message');
|
||||||
|
if (msgEl) msgEl.className = 'form-message';
|
||||||
|
|
||||||
|
var userIDsStr = form.querySelector('[name="user_ids"]').value.trim();
|
||||||
|
var amountStr = form.querySelector('[name="amount"]').value.trim();
|
||||||
|
var desc = form.querySelector('[name="description"]').value.trim();
|
||||||
|
var mode = form.querySelector('[name="mode"]').value;
|
||||||
|
|
||||||
|
if (!userIDsStr || !amountStr || !desc) {
|
||||||
|
showMsg(msgEl, '请填写完整信息', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userIDs = userIDsStr.split(',').map(function (s) { return parseInt(s.trim(), 10); }).filter(function (n) { return n > 0; });
|
||||||
|
var amount = parseInt(amountStr, 10);
|
||||||
|
if (userIDs.length === 0 || isNaN(amount) || amount === 0) {
|
||||||
|
showMsg(msgEl, '用户ID和金额格式不正确', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submitBtn) submitBtn.disabled = true;
|
||||||
|
|
||||||
|
api.post('/api/admin/energy/adjust', {
|
||||||
|
user_ids: userIDs,
|
||||||
|
amount: amount,
|
||||||
|
description: desc,
|
||||||
|
mode: mode
|
||||||
|
}, function (res) {
|
||||||
|
if (submitBtn) submitBtn.disabled = false;
|
||||||
|
if (res.success) {
|
||||||
|
showMsg(msgEl, res.message || '调整成功', 'success');
|
||||||
|
// 刷新公户余额
|
||||||
|
setTimeout(function () {
|
||||||
|
api.get('/api/admin/energy/fund-balance', function (r) {
|
||||||
|
if (r.success && r.data) {
|
||||||
|
var balEl = document.getElementById('fund-balance');
|
||||||
|
if (balEl) {
|
||||||
|
balEl.textContent = r.data.balance_display.toFixed(1);
|
||||||
|
balEl.className = 'fund-balance' + (r.data.balance < 0 ? ' negative' : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
} else {
|
||||||
|
showMsg(msgEl, res.message || '调整失败', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- 域能日志页 -----
|
||||||
|
function initEnergyLogPage() {
|
||||||
|
var page = 1;
|
||||||
|
var typeFilter = document.getElementById('log-type-filter');
|
||||||
|
|
||||||
|
function loadLogs() {
|
||||||
|
var logType = typeFilter ? typeFilter.value : '';
|
||||||
|
api.get('/api/admin/energy/logs?page=' + page + '&page_size=20&type=' + encodeURIComponent(logType), function (res) {
|
||||||
|
if (!res.success) return;
|
||||||
|
renderLogTable('energy-log-tbody', res.data.items, function (item) {
|
||||||
|
return [
|
||||||
|
item.id,
|
||||||
|
formatTime(item.created_at),
|
||||||
|
item.user_id,
|
||||||
|
'<span class="' + (item.amount >= 0 ? 'amount-positive' : 'amount-negative') + '">' + item.energy_display.toFixed(1) + '</span>',
|
||||||
|
'<span class="type-badge">' + (item.type_name || item.type) + '</span>',
|
||||||
|
escapeHtml(item.description || '')
|
||||||
|
];
|
||||||
|
}, 6);
|
||||||
|
renderPagination('energy-log-pagination', page, res.data.total_pages, function (p) {
|
||||||
|
page = p;
|
||||||
|
loadLogs();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeFilter) {
|
||||||
|
typeFilter.addEventListener('change', function () {
|
||||||
|
page = 1;
|
||||||
|
loadLogs();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- 公户流水页 -----
|
||||||
|
function initFundLogPage() {
|
||||||
|
var page = 1;
|
||||||
|
var typeFilter = document.getElementById('fund-log-type-filter');
|
||||||
|
|
||||||
|
function loadLogs() {
|
||||||
|
var logType = typeFilter ? typeFilter.value : '';
|
||||||
|
api.get('/api/admin/energy/fund-logs?page=' + page + '&page_size=20&type=' + encodeURIComponent(logType), function (res) {
|
||||||
|
if (!res.success) return;
|
||||||
|
renderLogTable('fund-log-tbody', res.data.items, function (item) {
|
||||||
|
return [
|
||||||
|
item.id,
|
||||||
|
formatTime(item.created_at),
|
||||||
|
'<span class="' + (item.amount >= 0 ? 'amount-positive' : 'amount-negative') + '">' + item.amount_display.toFixed(1) + '</span>',
|
||||||
|
'<span class="type-badge">' + (item.type_name || item.type) + '</span>',
|
||||||
|
escapeHtml(item.description || '')
|
||||||
|
];
|
||||||
|
}, 5);
|
||||||
|
renderPagination('fund-log-pagination', page, res.data.total_pages, function (p) {
|
||||||
|
page = p;
|
||||||
|
loadLogs();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeFilter) {
|
||||||
|
typeFilter.addEventListener('change', function () {
|
||||||
|
page = 1;
|
||||||
|
loadLogs();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- 通用工具函数 -----
|
||||||
|
function renderLogTable(tbodyId, items, rowFn, colCount) {
|
||||||
|
var tbody = document.getElementById(tbodyId);
|
||||||
|
if (!tbody) return;
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="' + colCount + '" class="empty-state">暂无记录</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
var cells = rowFn(items[i]);
|
||||||
|
html += '<tr>';
|
||||||
|
for (var j = 0; j < cells.length; j++) {
|
||||||
|
html += '<td>' + cells[j] + '</td>';
|
||||||
|
}
|
||||||
|
html += '</tr>';
|
||||||
|
}
|
||||||
|
tbody.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPagination(paginationId, current, total, onChange) {
|
||||||
|
var container = document.getElementById(paginationId);
|
||||||
|
if (!container || total <= 1) { if (container) container.innerHTML = ''; return; }
|
||||||
|
var html = '';
|
||||||
|
html += '<button' + (current <= 1 ? ' disabled' : '') + ' data-page="' + (current - 1) + '">‹ 上一页</button>';
|
||||||
|
var start = Math.max(1, current - 2);
|
||||||
|
var end = Math.min(total, current + 2);
|
||||||
|
for (var p = start; p <= end; p++) {
|
||||||
|
html += '<button class="' + (p === current ? 'active' : '') + '" data-page="' + p + '">' + p + '</button>';
|
||||||
|
}
|
||||||
|
html += '<button' + (current >= total ? ' disabled' : '') + ' data-page="' + (current + 1) + '">下一页 ›</button>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
var buttons = container.querySelectorAll('button');
|
||||||
|
for (var i = 0; i < buttons.length; i++) {
|
||||||
|
(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var p = parseInt(this.getAttribute('data-page'), 10);
|
||||||
|
if (p > 0 && p <= total && p !== current) {
|
||||||
|
onChange(p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})(buttons[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(isoStr) {
|
||||||
|
if (!isoStr) return '';
|
||||||
|
var d = new Date(isoStr);
|
||||||
|
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
|
||||||
|
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMsg(msgEl, text, type) {
|
||||||
|
if (!msgEl) return;
|
||||||
|
msgEl.textContent = text;
|
||||||
|
msgEl.className = 'form-message ' + type;
|
||||||
|
setTimeout(function () { msgEl.className = 'form-message'; }, 4000);
|
||||||
|
}
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user