Browse Source

feat(points): 添加手动调整积分功能并优化管理界面

- 在数据库迁移文件中为 biz_type 字段添加手动调整选项
- 实现管理员界面的手动调整积分功能,包括用户选择对话框
- 重构调整积分表单,支持增加和减少两种操作类型
- 添加积分数量验证确保为正整数
- 创建调整积分专用API接口 /admin/points/user_point/{id}/adjust
- 实现用户搜索和分页选择功能
- 优化积分变动逻辑验证,防止零值调整
- 更新管理界面UI交互体验
runphp 2 months ago
parent
commit
3cc65f8437

+ 1 - 1
database/migrations/20250830132345_user_point_log.php

@@ -39,7 +39,7 @@ class UserPointLog extends Migrator
             ->addColumn('after_point', Column::INTEGER, ['signed' => true, 'default' => 0, 'comment' => '变动后积分余额'])
             ->addColumn('after_freeze_point', Column::INTEGER, ['signed' => true, 'default' => 0, 'comment' => '变动后冻结积分余额'])
             ->addColumn('type', Column::INTEGER, ['signed' => false, 'default' => 0, 'comment' => '类型 1 新增 2 减少 3 对冲'])
-            ->addColumn('biz_type', Column::TINYINTEGER, ['signed' => false, 'default' => 0, 'comment' => '关联业务类型 1 订单'])
+            ->addColumn('biz_type', Column::TINYINTEGER, ['signed' => false, 'default' => 0, 'comment' => '关联业务类型 1 订单 2 手动调整'])
             ->addColumn('biz_id', Column::INTEGER, ['signed' => false, 'default' => 0, 'comment' => '关联业务ID'])
             ->addColumn('remark', Column::STRING, ['default' => '', 'comment' => '备注'])
             ->addTimestamps()

+ 334 - 86
resource/admin/PointsLog.vue

@@ -5,8 +5,7 @@
         <div class="card-header">
           <span>积分明细管理</span>
           <div class="header-actions">
-<!--            <el-button type="primary" @click="showAdjustDialog">手动调整积分</el-button>
-            <el-button @click="exportData">导出数据</el-button>-->
+            <el-button type="primary" @click="showAdjustDialog">手动调整积分</el-button>
           </div>
         </div>
       </template>
@@ -199,50 +198,34 @@
         :rules="adjustRules"
         label-width="100px"
       >
-        <el-form-item label="用户ID" prop="user_id">
+        <el-form-item label="选择用户" prop="user_id">
           <el-input
-            v-model="adjustForm.user_id"
-            placeholder="请输入用户ID"
+            v-model="selectedAdjustUserDisplay"
+            placeholder="点击选择用户"
+            readonly
+            style="cursor: pointer;"
+            @click="showAdjustUserDialog"
           />
         </el-form-item>
-        <el-form-item label="积分变动" prop="point">
-          <el-input-number
-            v-model="adjustForm.point"
-            :min="-999999"
-            :max="999999"
-            placeholder="正数为增加,负数为减少"
-            style="width: 100%;"
-          />
+        <el-form-item label="操作类型" prop="operation_type">
+          <el-radio-group v-model="adjustForm.operation_type">
+            <el-radio label="increase">增加积分</el-radio>
+            <el-radio label="decrease">减少积分</el-radio>
+          </el-radio-group>
         </el-form-item>
-        <el-form-item label="冻结积分" prop="freeze_point">
+        
+        <el-form-item label="积分数量" prop="point">
           <el-input-number
-            v-model="adjustForm.freeze_point"
-            :min="-999999"
+            v-model="adjustForm.point"
+            :min="1"
             :max="999999"
-            placeholder="冻结积分变动量"
+            :step="1"
+            placeholder="请输入积分数量"
             style="width: 100%;"
           />
         </el-form-item>
-        <el-form-item label="业务类型" prop="biz_type">
-          <el-select
-            v-model="adjustForm.biz_type"
-            placeholder="请选择业务类型"
-            style="width: 100%;"
-          >
-            <el-option
-              v-for="item in bizTypes"
-              :key="item.value"
-              :label="item.label"
-              :value="item.value"
-            />
-          </el-select>
-        </el-form-item>
-        <el-form-item label="业务ID" prop="biz_id">
-          <el-input
-            v-model="adjustForm.biz_id"
-            placeholder="关联的业务ID(选填)"
-          />
-        </el-form-item>
+
+
         <el-form-item label="备注" prop="remark">
           <el-input
             v-model="adjustForm.remark"
@@ -266,6 +249,69 @@
       </template>
     </el-dialog>
 
+    <!-- 调整积分用户选择对话框 -->
+    <el-dialog
+      v-model="adjustUserDialogVisible"
+      title="选择用户"
+      width="80%"
+    >
+      <div style="margin-bottom: 20px;">
+        <el-input
+          v-model="adjustUserSearchKeyword"
+          placeholder="请输入用户ID、昵称或手机号进行搜索"
+          clearable
+          style="width: 300px;"
+          @keyup.enter="handleAdjustUserSearch"
+        >
+          <template #append>
+            <el-button @click="handleAdjustUserSearch">搜索</el-button>
+          </template>
+        </el-input>
+        <el-button @click="resetAdjustUserSearch" style="margin-left: 10px;">重置</el-button>
+      </div>
+
+      <el-table :data="adjustUserList" @row-click="selectAdjustUser" v-loading="adjustUserLoading" max-height="400px">
+        <el-table-column prop="id" label="ID" min-width="80"></el-table-column>
+        <el-table-column label="头像" min-width="80">
+          <template #default="{ row }">
+            <el-avatar :size="40" :src="row.avatar" :alt="row.nickname">
+              <template #default>
+                <el-icon><User /></el-icon>
+              </template>
+            </el-avatar>
+          </template>
+        </el-table-column>
+        <el-table-column prop="nickname" label="昵称" min-width="80"></el-table-column>
+        <el-table-column prop="mobile" label="手机号" min-width="120">
+          <template #default="{ row }">
+            {{ row.mobile || '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="point" label="当前积分" min-width="100">
+          <template #default="{ row }">
+            <span class="point-value">{{ row.point || 0 }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="update_time" label="更新时间" min-width="200" />
+        <el-table-column label="操作" width="80" fixed="right">
+          <template #default="{ row }">
+            <el-button type="primary" size="small" @click="selectAdjustUser(row)">选择</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      
+      <el-pagination
+        :current-page="adjustUserPagination.page"
+        :page-size="adjustUserPagination.limit"
+        :total="adjustUserPagination.total"
+        :page-sizes="[10, 20, 50]"
+        layout="total, sizes, prev, pager, next"
+        style="margin-top: 20px; text-align: right;"
+        @size-change="handleAdjustUserSizeChange"
+        @current-change="handleAdjustUserCurrentChange"
+      />
+    </el-dialog>
+
     <!-- 详情对话框 -->
     <el-dialog
       v-model="detailDialogVisible"
@@ -309,6 +355,69 @@
       </el-descriptions>
     </el-dialog>
 
+    <!-- 调整积分用户选择对话框 -->
+    <el-dialog
+      v-model="adjustUserDialogVisible"
+      title="选择用户"
+      width="80%"
+    >
+      <div style="margin-bottom: 20px;">
+        <el-input
+          v-model="adjustUserSearchKeyword"
+          placeholder="请输入用户ID、昵称或手机号进行搜索"
+          clearable
+          style="width: 300px;"
+          @keyup.enter="handleAdjustUserSearch"
+        >
+          <template #append>
+            <el-button @click="handleAdjustUserSearch">搜索</el-button>
+          </template>
+        </el-input>
+        <el-button @click="resetAdjustUserSearch" style="margin-left: 10px;">重置</el-button>
+      </div>
+
+      <el-table :data="adjustUserList" @row-click="selectAdjustUser" v-loading="adjustUserLoading" max-height="400px">
+        <el-table-column prop="id" label="ID" min-width="80"></el-table-column>
+        <el-table-column label="头像" min-width="80">
+          <template #default="{ row }">
+            <el-avatar :size="40" :src="row.avatar" :alt="row.nickname">
+              <template #default>
+                <el-icon><User /></el-icon>
+              </template>
+            </el-avatar>
+          </template>
+        </el-table-column>
+        <el-table-column prop="nickname" label="昵称" min-width="80"></el-table-column>
+        <el-table-column prop="mobile" label="手机号" min-width="120">
+          <template #default="{ row }">
+            {{ row.mobile || '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="point" label="当前积分" min-width="100">
+          <template #default="{ row }">
+            <span class="point-value">{{ row.point || 0 }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="update_time" label="更新时间" min-width="200" />
+        <el-table-column label="操作" width="80" fixed="right">
+          <template #default="{ row }">
+            <el-button type="primary" size="small" @click="selectAdjustUser(row)">选择</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      
+      <el-pagination
+        :current-page="adjustUserPagination.page"
+        :page-size="adjustUserPagination.limit"
+        :total="adjustUserPagination.total"
+        :page-sizes="[10, 20, 50]"
+        layout="total, sizes, prev, pager, next"
+        style="margin-top: 20px; text-align: right;"
+        @size-change="handleAdjustUserSizeChange"
+        @current-change="handleAdjustUserCurrentChange"
+      />
+    </el-dialog>
+
     <!-- 用户选择对话框 -->
     <el-dialog
       v-model="userDialogVisible"
@@ -378,6 +487,7 @@
 
 export default {
   name: 'PointsLog',
+
   props: {
     axiosInstance: {
       type: Object,
@@ -422,25 +532,25 @@ export default {
       adjustFormRef: null,
       adjustForm: {
         user_id: '',
+        operation_type: 'increase', // 'increase' or 'decrease'
         point: 0,
-        freeze_point: 0,
-        biz_type: 4,
-        biz_id: '',
         remark: ''
       },
       adjustRules: {
         user_id: [
-          { required: true, message: '请输入用户ID', trigger: 'blur' },
-          { pattern: /^\d+$/, message: '用户ID必须是数字', trigger: 'blur' }
+          { required: true, message: '请选择用户', trigger: 'change' }
         ],
-        point: [
-          { required: true, message: '请输入积分变动量', trigger: 'blur' }
+        operation_type: [
+          { required: true, message: '请选择操作类型', trigger: 'change' }
         ],
-        biz_type: [
-          { required: true, message: '请选择业务类型', trigger: 'change' }
+        point: [
+          { required: true, message: '请输入积分数量', trigger: 'blur' },
+          { type: 'number', min: 1, message: '积分数量必须大于0', trigger: 'blur' },
+          { validator: this.validateInteger, message: '积分数量必须是整数', trigger: 'blur' }
         ],
+
         remark: [
-          { required: true, message: '请输入调整原因', trigger: 'blur' }
+          { required: true, message: '请输入积分变动备注', trigger: 'blur' }
         ]
       },
       // 详情对话框
@@ -455,6 +565,16 @@ export default {
         page: 1,
         limit: 20,
         total: 0
+      },
+      // 调整积分用户选择对话框
+      adjustUserDialogVisible: false,
+      adjustUserSearchKeyword: '',
+      adjustUserList: [],
+      adjustUserLoading: false,
+      adjustUserPagination: {
+        page: 1,
+        limit: 20,
+        total: 0
       }
     }
   },
@@ -469,6 +589,16 @@ export default {
       set(value) {
         // 不需要设置逻辑,因为显示和实际值分离
       }
+    },
+    selectedAdjustUserDisplay: {
+      get() {
+        if (!this.adjustForm.user_id) return ''
+        const selectedUser = this.adjustUserList.find(user => user.id.toString() === this.adjustForm.user_id)
+        return selectedUser ? selectedUser.nickname : this.adjustForm.user_id
+      },
+      set(value) {
+        // 不需要设置逻辑,因为显示和实际值分离
+      }
     }
   },
   mounted() {
@@ -494,6 +624,14 @@ export default {
       return item ? item.label : '未知'
     },
 
+    validateInteger(rule, value, callback) {
+      if (!Number.isInteger(Number(value))) {
+        callback(new Error('积分数量必须是整数'))
+      } else {
+        callback()
+      }
+    },
+
     // 获取积分记录列表
     async getUserPointLogs() {
       try {
@@ -557,39 +695,118 @@ export default {
       this.searchForm.user_id = ''
     },
 
-    // 显示调整对话框
-    showAdjustDialog() {
+    // 重置调整表单
+    resetAdjustForm() {
       Object.assign(this.adjustForm, {
         user_id: '',
+        operation_type: 'increase',
         point: 0,
-        freeze_point: 0,
-        biz_type: 4,
-        biz_id: '',
         remark: ''
       })
+    },
+
+    // 显示调整积分用户选择对话框
+    showAdjustUserDialog() {
+      this.adjustUserDialogVisible = true
+      this.getAdjustUserList()
+    },
+
+    // 获取调整积分用户列表
+    async getAdjustUserList() {
+      try {
+        this.adjustUserLoading = true
+        const params = {
+          page: this.adjustUserPagination.page,
+          limit: this.adjustUserPagination.limit,
+          keyword: this.adjustUserSearchKeyword
+        }
+        
+        const response = await this.axiosInstance.get('/points/user_point', { params })
+        this.adjustUserList = response.page.data || []
+        this.adjustUserPagination.total = response.page.total || 0
+      } catch (error) {
+        console.error('获取用户列表失败:', error)
+        this.$message.error('获取用户列表失败')
+      } finally {
+        this.adjustUserLoading = false
+      }
+    },
+
+    // 选择调整积分用户
+    selectAdjustUser(user) {
+      this.adjustForm.user_id = user.id.toString()
+      this.adjustUserDialogVisible = false
+      this.$message.success(`已选择用户: ${user.nickname} (ID: ${user.id})`)
+    },
+
+    // 搜索调整积分用户
+    handleAdjustUserSearch() {
+      this.adjustUserPagination.page = 1
+      this.getAdjustUserList()
+    },
+
+    // 重置调整积分用户搜索
+    resetAdjustUserSearch() {
+      this.adjustUserSearchKeyword = ''
+      this.adjustUserPagination.page = 1
+      this.getAdjustUserList()
+    },
+
+    // 调整积分用户列表分页处理
+    handleAdjustUserSizeChange(newSize) {
+      this.adjustUserPagination.limit = newSize
+      this.adjustUserPagination.page = 1
+      this.getAdjustUserList()
+    },
+
+    handleAdjustUserCurrentChange(newPage) {
+      this.adjustUserPagination.page = newPage
+      this.getAdjustUserList()
+    },
+
+    // 显示调整对话框
+    showAdjustDialog() {
+      this.resetAdjustForm()
       this.adjustDialogVisible = true
     },
 
     // 手动调整积分
     async handleAdjustPoints() {
       try {
-        // Note: In a real implementation, you'd need to properly validate the form
-        // This is a simplified version
+        // 验证表单
+        await this.$refs.adjustFormRef.validate()
+        
         this.adjustLoading = true
         
-        const data = { ...this.adjustForm }
-        if (!data.biz_id) {
-          delete data.biz_id
+        // 根据操作类型确定积分正负值和类型
+        let pointValue = parseInt(this.adjustForm.point, 10)
+        let typeValue = 1; // 默认为增加
+        if (this.adjustForm.operation_type === 'decrease') {
+          pointValue = -Math.abs(pointValue)
+          typeValue = 2; // 减少
+        } else {
+          pointValue = Math.abs(pointValue)
+          typeValue = 1; // 增加
         }
         
-        await this.axiosInstance.post('/points/adjust_user_points', data)
+        const data = {
+          user_id: this.adjustForm.user_id,
+          point: pointValue,
+          type: typeValue,
+          remark: this.adjustForm.remark
+        }
+        
+        await this.axiosInstance.put(`/points/user_point/${this.adjustForm.user_id}/adjust`, data)
         this.$message.success('积分调整成功')
         this.adjustDialogVisible = false
+        this.resetAdjustForm()
         this.getUserPointLogs()
       } catch (error) {
         console.error('积分调整失败:', error)
         if (error.message) {
           this.$message.error(error.message)
+        } else {
+          this.$message.error('积分调整失败,请检查输入信息')
         }
       } finally {
         this.adjustLoading = false
@@ -649,6 +866,65 @@ export default {
       this.$message.success(`已选择用户: ${user.nickname} (ID: ${user.id})`)
     },
 
+    // 显示调整积分用户选择对话框
+    showAdjustUserDialog() {
+      this.adjustUserDialogVisible = true
+      this.getAdjustUserList()
+    },
+
+    // 获取调整积分用户列表
+    async getAdjustUserList() {
+      try {
+        this.adjustUserLoading = true
+        const params = {
+          page: this.adjustUserPagination.page,
+          limit: this.adjustUserPagination.limit,
+          keyword: this.adjustUserSearchKeyword
+        }
+        
+        const response = await this.axiosInstance.get('/points/user_point', { params })
+        this.adjustUserList = response.page.data || []
+        this.adjustUserPagination.total = response.page.total || 0
+      } catch (error) {
+        console.error('获取用户列表失败:', error)
+        this.$message.error('获取用户列表失败')
+      } finally {
+        this.adjustUserLoading = false
+      }
+    },
+
+    // 选择调整积分用户
+    selectAdjustUser(user) {
+      this.adjustForm.user_id = user.id.toString()
+      this.adjustUserDialogVisible = false
+      this.$message.success(`已选择用户: ${user.nickname} (ID: ${user.id})`)
+    },
+
+    // 搜索调整积分用户
+    handleAdjustUserSearch() {
+      this.adjustUserPagination.page = 1
+      this.getAdjustUserList()
+    },
+
+    // 重置调整积分用户搜索
+    resetAdjustUserSearch() {
+      this.adjustUserSearchKeyword = ''
+      this.adjustUserPagination.page = 1
+      this.getAdjustUserList()
+    },
+
+    // 调整积分用户列表分页处理
+    handleAdjustUserSizeChange(newSize) {
+      this.adjustUserPagination.limit = newSize
+      this.adjustUserPagination.page = 1
+      this.getAdjustUserList()
+    },
+
+    handleAdjustUserCurrentChange(newPage) {
+      this.adjustUserPagination.page = newPage
+      this.getAdjustUserList()
+    },
+
     // 用户列表分页处理
     handleUserSizeChange(newSize) {
       this.userPagination.limit = newSize
@@ -661,35 +937,7 @@ export default {
       this.getUserList()
     },
 
-    // 导出数据
-    async exportData() {
-      try {
-        const params = { ...this.searchForm }
-        
-        // 处理时间范围
-        if (this.searchForm.dateRange && this.searchForm.dateRange.length === 2) {
-          params.start_time = this.searchForm.dateRange[0]
-          params.end_time = this.searchForm.dateRange[1]
-        }
-        delete params.dateRange
 
-        const response = await this.axiosInstance.get('/points/export_logs', { params })
-        
-        if (response.data.download_url) {
-          // 创建下载链接
-          const link = document.createElement('a')
-          link.href = response.data.download_url
-          link.download = response.data.filename || 'points_logs.xlsx'
-          document.body.appendChild(link)
-          link.click()
-          document.body.removeChild(link)
-          this.$message.success('导出成功')
-        }
-      } catch (error) {
-        console.error('导出失败:', error)
-        this.$message.error('导出失败')
-      }
-    }
   }
 }
 </script>

+ 13 - 7
route/admin.php

@@ -8,13 +8,19 @@ use SixShop\System\Middleware\MacroPageMiddleware;
 // 后台管理API路由
 // 路由前缀: /admin/points
 
-Route::resource('user_point', UserPointController::class)->middleware([
-    MacroPageMiddleware::class,
-    'auth'
-])->option([
-    'name' => 'points:user_point',
-    'description' => '用户积分',
-]);
+Route::group('user_point', function () {
+    Route::put(':id/adjust', [UserPointController::class, 'adjust'])->option([
+        'name' => 'points:user_point:adjust',
+        'description' => '调整用户积分',
+    ]);
+    Route::resource('', UserPointController::class)->middleware([
+        MacroPageMiddleware::class,
+        'auth'
+    ])->option([
+        'name' => 'points:user_point',
+        'description' => '用户积分',
+    ]);
+})->middleware(['auth']);
 
 Route::group('user_point_log', function () {
     Route::get('user', [UserPointLogController::class, 'user'])->middleware([

+ 23 - 0
src/Controller/UserPointController.php

@@ -1,11 +1,15 @@
 <?php
 declare(strict_types=1);
+
 namespace SixShop\Points\Controller;
 
 use SixShop\Core\Request;
 use SixShop\Points\Entity\UserPointEntity;
+use SixShop\Points\Enum\UserPointTypeEnum;
 use think\Response;
 use function SixShop\Core\page_response;
+use function SixShop\Core\success_response;
+use function SixShop\Core\throw_logic_exception;
 
 class UserPointController
 {
@@ -17,4 +21,23 @@ class UserPointController
         $paginate = $entity->getUserList($params, $request->pageAndLimit());
         return page_response($paginate);
     }
+
+    public function adjust(int $id, Request $request, UserPointEntity $entity): Response
+    {
+        $params = $request->put([
+            'point/d',
+            'type/d',
+            'remark/s',
+            'freeze_point/d' => 0,
+        ]);
+        return success_response($entity->change(
+            $id,
+            $params['point'],
+            UserPointTypeEnum::from($params['type']),
+            2,
+            0,
+            $params['remark'],
+            $params['freeze_point'],
+        ));
+    }
 }

+ 14 - 9
src/Entity/UserPointEntity.php

@@ -1,5 +1,6 @@
 <?php
 declare(strict_types=1);
+
 namespace SixShop\Points\Entity;
 
 use SixShop\Core\Entity\BaseEntity;
@@ -7,19 +8,23 @@ use SixShop\Points\Enum\UserPointTypeEnum;
 use SixShop\Points\Model\UserPointLogModel;
 use think\db\Query;
 use think\Paginator;
+use function SixShop\Core\throw_logic_exception;
 
 class UserPointEntity extends BaseEntity
 {
     public function change(
-        int $userID,
-        int $point,
+        int               $userID,
+        int               $point,
         UserPointTypeEnum $type,
-        int $bizType,
-        int $bizID,
-        string $remark,
-        int $freezePoint = 0,
+        int               $bizType,
+        int               $bizID,
+        string            $remark,
+        int               $freezePoint = 0,
     ): self
     {
+        if ($point == 0 && $freezePoint == 0) {
+            throw_logic_exception('积分调整不能为0');
+        }
         $userPoint = $this->where('user_id', $userID)->findOrEmpty();
         if ($userPoint->isEmpty()) {
             $userPoint->save([
@@ -31,7 +36,7 @@ class UserPointEntity extends BaseEntity
             $userPoint->inc('point', $point)
                 ->inc('freeze_point', $freezePoint)->save();
         }
-        UserPointLogModel::create( [
+        UserPointLogModel::create([
             'user_id' => $userID,
             'point' => $point,
             'type' => $type,
@@ -49,11 +54,11 @@ class UserPointEntity extends BaseEntity
     {
         return $this->alias('l')
             ->leftJoin('user u', 'u.id = l.user_id')
-            ->withSearch(['keyword' =>function (Query $query, string $keyword){
+            ->withSearch(['keyword' => function (Query $query, string $keyword) {
                 $keyword && $query->where('u.nickname|u.mobile|u.id', 'like', $keyword);
             }], $params)
             ->group('l.user_id')
-            ->field( ['u.id', 'u.avatar', 'u.nickname', 'u.mobile', 'l.point', 'l.update_time'])
+            ->field(['u.id', 'u.avatar', 'u.nickname', 'u.mobile', 'l.point', 'l.update_time'])
             ->paginate($pageAndLimit);
     }
 }