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 {
|
||||
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)
|
||||
}
|
||||
|
||||
// 初始化公户行(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 的行)
|
||||
// 支持软删除用户邮箱复用
|
||||
migrations := []string{
|
||||
|
||||
@ -52,6 +52,7 @@ var (
|
||||
ErrInsufficientEnergy = errors.New("域能不足,可通过每日签到或创作被赋能获取")
|
||||
ErrEnergizeLimitReached = errors.New("对该文章赋能已达上限")
|
||||
ErrCreatePostNoExp = errors.New("经验不足,Lv1 解锁投稿")
|
||||
ErrInsufficientFund = errors.New("公户余额不足,无法执行操作")
|
||||
|
||||
// 评论相关
|
||||
ErrCommentNotFound = errors.New("评论不存在")
|
||||
|
||||
@ -10,10 +10,12 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// adminEnergyUseCase AdminEnergyController 对 EnergyService 的最小依赖(ISP:2 个方法)
|
||||
// adminEnergyUseCase AdminEnergyController 对 EnergyService 的最小依赖(ISP:4 个方法)
|
||||
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)
|
||||
GetFundBalance() (int, error)
|
||||
GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error)
|
||||
}
|
||||
|
||||
// AdminEnergyController 后台域能管理控制器
|
||||
@ -26,17 +28,38 @@ func NewAdminEnergyController(energySvc adminEnergyUseCase) *AdminEnergyControll
|
||||
return &AdminEnergyController{energySvc: energySvc}
|
||||
}
|
||||
|
||||
// EnergyPage 域能管理页面
|
||||
// EnergyPage 域能管理页面(公户余额 + 用户域能调整)
|
||||
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{
|
||||
"Title": "域能管理",
|
||||
"Title": "域能管理",
|
||||
"ExtraCSS": "/admin/static/css/energy.css",
|
||||
"ExtraJS": "/admin/static/js/energy.js",
|
||||
"FundBalance": balance,
|
||||
"FundBalanceDisplay": float64(balance) / 10,
|
||||
}))
|
||||
}
|
||||
|
||||
// EnergyLogPage 域能日志页面
|
||||
func (ac *AdminEnergyController) EnergyLogPage(c *gin.Context) {
|
||||
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"`
|
||||
Amount int `json:"amount" binding:"required"`
|
||||
Description string `json:"description" binding:"required,min=1,max=500"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "参数错误:需要 user_ids、amount 和 description")
|
||||
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, "调整失败")
|
||||
return
|
||||
}
|
||||
@ -101,3 +143,51 @@ func (ac *AdminEnergyController) ListEnergyLogs(c *gin.Context) {
|
||||
"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
|
||||
}
|
||||
|
||||
// energyAdminUseCase AdminEnergyController 对 EnergyService 的依赖(ISP:2 个方法)
|
||||
// energyAdminUseCase AdminEnergyController 对 EnergyService 的依赖(ISP:4 个方法)
|
||||
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)
|
||||
GetFundBalance() (int, error)
|
||||
GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error)
|
||||
}
|
||||
|
||||
// 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))
|
||||
adminPages.GET("/energy", d.adminEnergyCtrl.EnergyPage)
|
||||
adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage)
|
||||
adminPages.GET("/energy/fund-logs", d.adminEnergyCtrl.FundLogPage)
|
||||
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)
|
||||
}
|
||||
|
||||
// --- 域能管理 API(admin+,调整需要 owner) ---
|
||||
// --- 域能管理 API(admin+,system_operation 仅 owner) ---
|
||||
adminEnergyAPI := r.Group("/api/admin/energy")
|
||||
adminEnergyAPI.Use(d.authMdw.Required())
|
||||
adminEnergyAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
|
||||
adminEnergyAPI.Use(d.authMdw.BannedWriteGuard())
|
||||
adminEnergyAPI.Use(middleware.CSRF(cfg))
|
||||
{
|
||||
adminEnergyAPI.POST("/adjust", d.adminEnergyCtrl.AdjustEnergy,
|
||||
middleware.RequireMinRole(model.RoleOwner))
|
||||
adminEnergyAPI.POST("/adjust", d.adminEnergyCtrl.AdjustEnergy)
|
||||
adminEnergyAPI.GET("/logs", d.adminEnergyCtrl.ListEnergyLogs)
|
||||
adminEnergyAPI.GET("/fund-balance", d.adminEnergyCtrl.GetFundBalance)
|
||||
adminEnergyAPI.GET("/fund-logs", d.adminEnergyCtrl.ListFundLogs)
|
||||
}
|
||||
|
||||
// --- 评论管理 API(admin+) ---
|
||||
|
||||
@ -21,7 +21,9 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
||||
|
||||
// 域能系统
|
||||
energyRepo := repository.NewEnergyRepo(db)
|
||||
fundRepo := repository.NewFundRepo(db)
|
||||
energyService := service.NewEnergyService(db, energyRepo, nil, notificationService)
|
||||
energyService.SetFundRepo(fundRepo)
|
||||
|
||||
// 等级/签到/任务系统(链式注入域能服务)
|
||||
_, levelSvc, levelCtrl := buildDepsLevel(db, notificationService, authMdw)
|
||||
|
||||
@ -19,6 +19,7 @@ type expAdder interface {
|
||||
type EnergyService struct {
|
||||
db *gorm.DB
|
||||
repo EnergyStore
|
||||
fundRepo FundStore
|
||||
expSvc expAdder
|
||||
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}
|
||||
}
|
||||
|
||||
// SetExpAdder 链式注入经验服务(用于赋能获得经验)
|
||||
func (s *EnergyService) SetExpAdder(svc expAdder) {
|
||||
s.expSvc = svc
|
||||
}
|
||||
|
||||
// SetFundRepo 链式注入公户仓储
|
||||
func (s *EnergyService) SetFundRepo(repo FundStore) {
|
||||
s.fundRepo = repo
|
||||
}
|
||||
|
||||
// EnergyInfo 域能信息(含每日赋能经验状态)
|
||||
type EnergyInfo struct {
|
||||
Energy int // 域能余额(乘10存储)
|
||||
@ -36,11 +47,6 @@ type EnergyInfo struct {
|
||||
DailyExpCapReached bool // 是否已达上限
|
||||
}
|
||||
|
||||
// SetExpAdder 链式注入经验服务(用于赋能获得经验)
|
||||
func (s *EnergyService) SetExpAdder(svc expAdder) {
|
||||
s.expSvc = svc
|
||||
}
|
||||
|
||||
// 域能相关常量(乘10存储,DB值)
|
||||
const (
|
||||
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 {
|
||||
txRepo := s.repo.WithTx(tx)
|
||||
var txFundRepo FundStore
|
||||
if s.fundRepo != nil {
|
||||
txFundRepo = s.fundRepo.WithTx(tx)
|
||||
}
|
||||
|
||||
// 1. 检查帖子存在,获取作者
|
||||
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)
|
||||
}
|
||||
|
||||
// 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")
|
||||
now := time.Now().In(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
|
||||
}
|
||||
|
||||
// 11. 事务提交后,在事务外增加经验 + 更新每日汇总
|
||||
// 必须在事务外执行:LevelService.AddExp 也操作 users 表,
|
||||
// 若在事务内调用会造成连接池不同连接间行锁死锁。
|
||||
// 12. 事务提交后,在事务外增加经验 + 更新每日汇总
|
||||
if needExpUpdate {
|
||||
if s.expSvc != nil {
|
||||
if _, _, err := s.expSvc.AddExp(energizerID, expGained); err != nil {
|
||||
@ -226,46 +255,91 @@ func (s *EnergyService) CheckInEnergy(userID uint) error {
|
||||
}
|
||||
|
||||
// DeductOnRename 改名扣域能(首次改名免费,不调用此方法)
|
||||
// 事务包裹:扣用户域能 + 入公户 + 写流水
|
||||
func (s *EnergyService) DeductOnRename(userID uint) error {
|
||||
// 检查域能余额(非 owner 需 ≥ 0)
|
||||
user, err := s.repo.FindUserByID(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询用户失败: %w", err)
|
||||
}
|
||||
if user.Role != model.RoleOwner && user.Energy+EnergyRename < 0 {
|
||||
return common.ErrInsufficientEnergy
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
txRepo := s.repo.WithTx(tx)
|
||||
|
||||
if err := s.repo.AddEnergy(userID, EnergyRename); err != nil {
|
||||
return fmt.Errorf("扣减改名域能失败: %w", err)
|
||||
}
|
||||
// 检查域能余额(非 owner 需 ≥ 0)
|
||||
user, err := txRepo.FindUserByID(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询用户失败: %w", err)
|
||||
}
|
||||
if user.Role != model.RoleOwner && user.Energy+EnergyRename < 0 {
|
||||
return common.ErrInsufficientEnergy
|
||||
}
|
||||
|
||||
log := &model.EnergyLog{
|
||||
UserID: userID,
|
||||
Amount: EnergyRename,
|
||||
Type: model.EnergyTypeRename,
|
||||
Description: "改名消耗 6.0 域能",
|
||||
}
|
||||
return s.repo.CreateEnergyLog(log)
|
||||
if err := txRepo.AddEnergy(userID, EnergyRename); err != nil {
|
||||
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{
|
||||
UserID: userID,
|
||||
Amount: EnergyRename,
|
||||
Type: model.EnergyTypeRename,
|
||||
Description: "改名消耗 6.0 域能",
|
||||
}
|
||||
return txRepo.CreateEnergyLog(log)
|
||||
})
|
||||
}
|
||||
|
||||
// RefundOnRenameReject 改名审核被拒,返还域能
|
||||
// RefundOnRenameReject 改名审核被拒,从公户返还域能
|
||||
func (s *EnergyService) RefundOnRenameReject(userID uint) error {
|
||||
if err := s.repo.AddEnergy(userID, EnergyRenameRefund); err != nil {
|
||||
return fmt.Errorf("返还改名域能失败: %w", err)
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
txRepo := s.repo.WithTx(tx)
|
||||
|
||||
log := &model.EnergyLog{
|
||||
UserID: userID,
|
||||
Amount: EnergyRenameRefund,
|
||||
Type: model.EnergyTypeRenameRefund,
|
||||
Description: "改名审核被拒,返还 6.0 域能",
|
||||
}
|
||||
return s.repo.CreateEnergyLog(log)
|
||||
// 从公户支出(允许透支)
|
||||
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)
|
||||
}
|
||||
|
||||
log := &model.EnergyLog{
|
||||
UserID: userID,
|
||||
Amount: EnergyRenameRefund,
|
||||
Type: model.EnergyTypeRenameRefund,
|
||||
Description: "改名审核被拒,返还 6.0 域能",
|
||||
}
|
||||
return txRepo.CreateEnergyLog(log)
|
||||
})
|
||||
}
|
||||
|
||||
// DeductOnDeletePost 删稿扣域能(扣文章作者的域能,无视负数)
|
||||
// 事务包裹:确保扣域能 + 写流水原子性
|
||||
// 事务包裹:扣用户域能 + 入公户 + 写流水
|
||||
func (s *EnergyService) DeductOnDeletePost(authorUserID uint, postID uint) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
txRepo := s.repo.WithTx(tx)
|
||||
@ -274,6 +348,24 @@ func (s *EnergyService) DeductOnDeletePost(authorUserID uint, postID uint) error
|
||||
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{
|
||||
UserID: authorUserID,
|
||||
Amount: EnergyDeletePost,
|
||||
@ -287,25 +379,87 @@ func (s *EnergyService) DeductOnDeletePost(authorUserID uint, postID uint) error
|
||||
}
|
||||
|
||||
// AdminAdjust 后台调整用户域能
|
||||
func (s *EnergyService) AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string) error {
|
||||
for _, uid := range userIDs {
|
||||
if err := s.repo.AddEnergy(uid, amount); err != nil {
|
||||
return fmt.Errorf("调整用户 %d 域能失败: %w", uid, err)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
opUID := operatorUID
|
||||
log := &model.EnergyLog{
|
||||
UserID: uid,
|
||||
Amount: amount,
|
||||
Type: model.EnergyTypeAdminAdjust,
|
||||
Description: description,
|
||||
OperatorUID: &opUID,
|
||||
// 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 err := s.repo.CreateEnergyLog(log); err != nil {
|
||||
return fmt.Errorf("创建调整流水失败: %w", err)
|
||||
if balance < amount {
|
||||
return common.ErrInsufficientFund
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
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 {
|
||||
if err := txRepo.AddEnergy(uid, amount); err != nil {
|
||||
return fmt.Errorf("调整用户 %d 域能失败: %w", uid, err)
|
||||
}
|
||||
|
||||
opUID := operatorUID
|
||||
energyLog := &model.EnergyLog{
|
||||
UserID: uid,
|
||||
Amount: amount,
|
||||
Type: model.EnergyTypeAdminAdjust,
|
||||
Description: description,
|
||||
OperatorUID: &opUID,
|
||||
}
|
||||
if err := txRepo.CreateEnergyLog(energyLog); err != nil {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
// getFundModeName 返回操作模式中文名
|
||||
func getFundModeName(mode string) string {
|
||||
switch mode {
|
||||
case model.FundTypeAdminTransfer:
|
||||
return "管理转出"
|
||||
case model.FundTypeSystemOperation:
|
||||
return "系统操作"
|
||||
default:
|
||||
return "后台调整"
|
||||
}
|
||||
}
|
||||
|
||||
// GetEnergyInfo 获取用户域能余额和每日赋能经验状态
|
||||
@ -359,3 +513,28 @@ func (s *EnergyService) GetAdminEnergyLogs(energyType string, page, pageSize int
|
||||
p.DefaultPagination()
|
||||
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 向后兼容别名
|
||||
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)
|
||||
type energyNotifier interface {
|
||||
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" .}}
|
||||
<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" .}}
|
||||
|
||||
@ -1,2 +1,31 @@
|
||||
{{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" .}}
|
||||
|
||||
@ -48,9 +48,13 @@
|
||||
域能管理
|
||||
</a>
|
||||
<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 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}}">
|
||||
<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