Ver código fonte

feat: 后台全部列表页支持点击列头排序

后端:
- SortInterceptor 从缓存 body 读 sort 写入 SortContext(原只读 URL query,
  前端发 body 永远读不到),激活代码库 307 处已有 SortUtil.applySort 无参调用

前端:
- SortMixin 补 onSortToggle 方法(修复 20 个文件 onSortToggle is not a function)
- SortMixin 新增 reloadAfterSort,兼容 loadList/loadData/loadTasks/loadPlans
  四种加载函数名,解决点击表头不刷新问题
- 107 个列表页接入 SortMixin(admin/teacher/nutritionist/root 各模块)
- 3 个位置参数 API 封装(guidePackage/guideTask/teacherMessage)加可选 sort 参数

跳过:配置页/详情页/组件/仪表盘/树形表等非列表页(23 个)
Sisyphus 2 meses atrás
pai
commit
14b2fdec55
100 arquivos alterados com 2542 adições e 273 exclusões
  1. 43 6
      cfc-backend/src/main/java/com/etotem/cfc/config/SortInterceptor.java
  2. 3 2
      cfc-web/src/api/guidePackage.js
  3. 4 2
      cfc-web/src/api/guideTask.js
  4. 4 2
      cfc-web/src/api/teacherMessage.js
  5. 21 1
      cfc-web/src/mixins/SortMixin.js
  6. 25 1
      cfc-web/src/views/Children.vue
  7. 20 0
      cfc-web/src/views/Families.vue
  8. 24 2
      cfc-web/src/views/GuideFamilyTask.vue
  9. 21 1
      cfc-web/src/views/GuidePackages.vue
  10. 22 3
      cfc-web/src/views/OperationLogs.vue
  11. 20 0
      cfc-web/src/views/Points.vue
  12. 20 0
      cfc-web/src/views/Rewards.vue
  13. 20 0
      cfc-web/src/views/TaskTemplates.vue
  14. 21 1
      cfc-web/src/views/TeacherConsult.vue
  15. 21 0
      cfc-web/src/views/Wishes.vue
  16. 31 3
      cfc-web/src/views/admin/Activities.vue
  17. 23 1
      cfc-web/src/views/admin/ActivityRegistrationReview.vue
  18. 28 2
      cfc-web/src/views/admin/ActivityReview.vue
  19. 28 3
      cfc-web/src/views/admin/ArticleCategory.vue
  20. 25 2
      cfc-web/src/views/admin/AssessmentAssign.vue
  21. 22 1
      cfc-web/src/views/admin/AssessmentOrders.vue
  22. 23 3
      cfc-web/src/views/admin/AssessmentProducts.vue
  23. 33 4
      cfc-web/src/views/admin/BadgeManage.vue
  24. 27 3
      cfc-web/src/views/admin/BaziConfigs.vue
  25. 27 3
      cfc-web/src/views/admin/BloodTypeConfigs.vue
  26. 29 2
      cfc-web/src/views/admin/ButlerAssignments.vue
  27. 40 6
      cfc-web/src/views/admin/CfValueManage.vue
  28. 23 2
      cfc-web/src/views/admin/CommentReview.vue
  29. 28 4
      cfc-web/src/views/admin/CouponGrantLog.vue
  30. 39 6
      cfc-web/src/views/admin/DanExecution.vue
  31. 40 4
      cfc-web/src/views/admin/DanSettlement.vue
  32. 32 4
      cfc-web/src/views/admin/EcomSupplierManage.vue
  33. 22 1
      cfc-web/src/views/admin/EmotionAlertManage.vue
  34. 21 3
      cfc-web/src/views/admin/EnergyBehaviorConfig.vue
  35. 21 2
      cfc-web/src/views/admin/EnergyRuleManagement.vue
  36. 29 3
      cfc-web/src/views/admin/FamilyEarnings.vue
  37. 24 2
      cfc-web/src/views/admin/FamilyEarningsWithdraw.vue
  38. 12 2
      cfc-web/src/views/admin/FileManage.vue
  39. 29 3
      cfc-web/src/views/admin/GrowthTaskManagement.vue
  40. 25 3
      cfc-web/src/views/admin/HealthCheckinManage.vue
  41. 25 3
      cfc-web/src/views/admin/HealthPlanManage.vue
  42. 34 9
      cfc-web/src/views/admin/InventoryManage.vue
  43. 28 3
      cfc-web/src/views/admin/KnowledgeTag.vue
  44. 10 2
      cfc-web/src/views/admin/MembershipCenter.vue
  45. 25 2
      cfc-web/src/views/admin/NoticeManage.vue
  46. 27 3
      cfc-web/src/views/admin/NotificationTemplateManage.vue
  47. 19 0
      cfc-web/src/views/admin/NutritionProducts.vue
  48. 24 3
      cfc-web/src/views/admin/PendingRefund.vue
  49. 28 3
      cfc-web/src/views/admin/PeriodicServiceConfig.vue
  50. 29 4
      cfc-web/src/views/admin/PlanManagement.vue
  51. 22 1
      cfc-web/src/views/admin/PointsLog.vue
  52. 33 5
      cfc-web/src/views/admin/ProblemDomainManage.vue
  53. 26 4
      cfc-web/src/views/admin/ProductProfitRate.vue
  54. 19 2
      cfc-web/src/views/admin/PromotionManagement.vue
  55. 24 2
      cfc-web/src/views/admin/PurchaseManagement.vue
  56. 28 3
      cfc-web/src/views/admin/ReportAutoLearn.vue
  57. 28 3
      cfc-web/src/views/admin/ReportCollectorTrainer.vue
  58. 26 2
      cfc-web/src/views/admin/ReportManagement.vue
  59. 28 3
      cfc-web/src/views/admin/ReportParserImport.vue
  60. 28 3
      cfc-web/src/views/admin/ReportTemplateManage.vue
  61. 28 3
      cfc-web/src/views/admin/ReportTypeManagement.vue
  62. 28 3
      cfc-web/src/views/admin/ReportUnknownCluster.vue
  63. 29 3
      cfc-web/src/views/admin/SeasonalFoods.vue
  64. 28 2
      cfc-web/src/views/admin/SubscriptionManagement.vue
  65. 34 3
      cfc-web/src/views/admin/SupplierProductManage.vue
  66. 31 3
      cfc-web/src/views/admin/SurveyTemplates.vue
  67. 20 2
      cfc-web/src/views/admin/SysConfig.vue
  68. 27 2
      cfc-web/src/views/admin/SystemPromptManagement.vue
  69. 26 2
      cfc-web/src/views/admin/UnifiedHealthReports.vue
  70. 32 4
      cfc-web/src/views/admin/VirtualTeamList.vue
  71. 27 3
      cfc-web/src/views/admin/ZodiacConfigs.vue
  72. 32 3
      cfc-web/src/views/admin/data-source-config.vue
  73. 26 4
      cfc-web/src/views/admin/gates.vue
  74. 27 2
      cfc-web/src/views/admin/health-knowledge.vue
  75. 31 4
      cfc-web/src/views/admin/indicators/index.vue
  76. 15 1
      cfc-web/src/views/admin/review/ActivityAudit.vue
  77. 18 3
      cfc-web/src/views/admin/review/ActivityRegistrationAudit.vue
  78. 23 4
      cfc-web/src/views/admin/review/ArticleAudit.vue
  79. 17 2
      cfc-web/src/views/admin/review/ButlerAudit.vue
  80. 18 3
      cfc-web/src/views/admin/review/GuideAudit.vue
  81. 17 2
      cfc-web/src/views/admin/review/HierarchyAudit.vue
  82. 18 3
      cfc-web/src/views/admin/review/NutritionistAudit.vue
  83. 22 3
      cfc-web/src/views/admin/review/PackageAudit.vue
  84. 17 2
      cfc-web/src/views/admin/review/ProductAudit.vue
  85. 17 2
      cfc-web/src/views/admin/review/VendorAudit.vue
  86. 24 5
      cfc-web/src/views/admin/review/WithdrawAudit.vue
  87. 39 1
      cfc-web/src/views/admin/supply-hierarchy/ChangeRequest.vue
  88. 40 3
      cfc-web/src/views/admin/supply-manage/List.vue
  89. 37 3
      cfc-web/src/views/admin/supply-system/List.vue
  90. 24 4
      cfc-web/src/views/health/HealthReportAudit.vue
  91. 29 4
      cfc-web/src/views/nutritionist/HealthCheckins.vue
  92. 29 4
      cfc-web/src/views/nutritionist/HealthIndicators.vue
  93. 29 6
      cfc-web/src/views/nutritionist/HealthReports.vue
  94. 31 3
      cfc-web/src/views/nutritionist/NutritionMappings.vue
  95. 31 4
      cfc-web/src/views/teacher/FamilyList.vue
  96. 31 4
      cfc-web/src/views/teacher/GrowthPlans.vue
  97. 30 3
      cfc-web/src/views/teacher/GrowthRecords.vue
  98. 29 4
      cfc-web/src/views/teacher/HealthPlanReview.vue
  99. 26 2
      cfc-web/src/views/teacher/ProxyReport.vue
  100. 24 2
      cfc-web/src/views/teacher/TeacherAssessment.vue

+ 43 - 6
cfc-backend/src/main/java/com/etotem/cfc/config/SortInterceptor.java

@@ -6,6 +6,7 @@ import org.springframework.web.servlet.HandlerInterceptor;
 
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
 
@@ -16,18 +17,54 @@ public class SortInterceptor implements HandlerInterceptor {
     public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
         String sortJson = request.getParameter("sort");
         if (sortJson != null && !sortJson.isEmpty()) {
+            parseAndSetSort(sortJson);
+            return true;
+        }
+        byte[] cachedBody = com.etotem.cfc.config.RequestBodyCachingFilter.getCachedBody();
+        if (cachedBody != null && cachedBody.length > 0) {
             try {
                 @SuppressWarnings("unchecked")
-                List<Map<String, String>> sortSpecs = new com.fasterxml.jackson.databind.ObjectMapper()
-                        .readValue(sortJson, List.class);
-                SortContext.set(sortSpecs);
-            } catch (Exception e) {
-                // ignore invalid sort param
-            }
+                Map<String, Object> body = new com.fasterxml.jackson.databind.ObjectMapper()
+                        .readValue(cachedBody, Map.class);
+                Object sortObj = body.get("sort");
+                if (sortObj != null) {
+                    @SuppressWarnings("unchecked")
+                    List<Map<String, String>> sortSpecs = (List<Map<String, String>>) sortObj;
+                    // 白名单:只保留 column/order,过滤非法字段
+                    List<Map<String, String>> sanitized = Arrays.stream(sortSpecs.toArray(new Map[0]))
+                            .filter(m -> m != null)
+                            .map(m -> {
+                                Map<String, String> safe = new java.util.LinkedHashMap<>();
+                                Object col = m.get("column");
+                                Object order = m.get("order");
+                                if (col != null && order != null) {
+                                    safe.put("column", col.toString());
+                                    safe.put("order", order.toString().toLowerCase());
+                                }
+                                return safe;
+                            })
+                            .filter(m -> !m.isEmpty())
+                            .collect(java.util.stream.Collectors.toList());
+                    if (!sanitized.isEmpty()) {
+                        SortContext.set(sanitized);
+                    }
+                }
+            } catch (Exception ignored) {}
         }
         return true;
     }
 
+    private void parseAndSetSort(String sortJson) {
+        try {
+            @SuppressWarnings("unchecked")
+            List<Map<String, String>> sortSpecs = new com.fasterxml.jackson.databind.ObjectMapper()
+                    .readValue(sortJson, List.class);
+            SortContext.set(sortSpecs);
+        } catch (Exception ignored) {
+            // ignore invalid sort param
+        }
+    }
+
     @Override
     public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
         SortContext.clear();

+ 3 - 2
cfc-web/src/api/guidePackage.js

@@ -1,10 +1,11 @@
 import request from '@/utils/request'
 
 // 获取我的任务模板列表
-export function getMyPackages() {
+export function getMyPackages(data) {
   return request({
     url: '/api/packages/my',
-    method: 'post'
+    method: 'post',
+    data: data || {}
   })
 }
 

+ 4 - 2
cfc-web/src/api/guideTask.js

@@ -17,8 +17,10 @@ export function fetchFamilyOverview(familyId) {
 /**
  * 成长规划师获取孩子任务列表
  */
-export function fetchChildTasks(familyId, memberId, page = 1, size = 10, status) {
-  return request.post(`/api/guide/families/${familyId}/children/${memberId}/tasks`, { page, size, status })
+export function fetchChildTasks(familyId, memberId, page = 1, size = 10, status, sort) {
+  const data = { page, size, status }
+  if (sort) data.sort = sort
+  return request.post(`/api/guide/families/${familyId}/children/${memberId}/tasks`, data)
 }
 
 /**

+ 4 - 2
cfc-web/src/api/teacherMessage.js

@@ -17,8 +17,10 @@ export function replyConsultMessage(messageId, replyContent) {
 /**
  * 成长规划师获取咨询消息列表
  */
-export function getConsultMessages(page = 1, size = 10, status) {
-  return request.post('/api/guide/messages/list', { page, size, status })
+export function getConsultMessages(page = 1, size = 10, status, sort) {
+  const data = { page, size, status }
+  if (sort) data.sort = sort
+  return request.post('/api/guide/messages/list', data)
 }
 
 /**

+ 21 - 1
cfc-web/src/mixins/SortMixin.js

@@ -54,7 +54,27 @@ export default {
         // 新列:追加到排序末尾(多列排序)
         this.sortState.push({ column: col, order: 'asc' })
       }
-      if (typeof this.loadList === 'function') this.loadList()
+      this.reloadAfterSort()
+    },
+    onSortToggle(col) {
+      const idx = this.sortState.findIndex(s => s.column === col)
+      if (idx >= 0) {
+        this.sortState.splice(idx, 1)
+      } else {
+        this.sortState.push({ column: col, order: 'asc' })
+      }
+      this.reloadAfterSort()
+    },
+    reloadAfterSort() {
+      if (typeof this.loadList === 'function') {
+        this.loadList()
+      } else if (typeof this.loadData === 'function') {
+        this.loadData()
+      } else if (typeof this.loadTasks === 'function') {
+        this.loadTasks()
+      } else if (typeof this.loadPlans === 'function') {
+        this.loadPlans()
+      }
     },
     clearSort() {
       this.sortState = []

+ 25 - 1
cfc-web/src/views/Children.vue

@@ -40,7 +40,11 @@
 
         <!-- 孩子列表 -->
         <el-table :data="tableData" border v-loading="loading" style="width: 100%" :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="80"></el-table-column>
+        <el-table-column prop="id" label="ID" width="80">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="nickname" label="昵称" width="150"></el-table-column>
         <el-table-column prop="age" label="年龄" width="80"></el-table-column>
         <el-table-column prop="totalPoints" label="当前积分" width="120">
@@ -65,6 +69,9 @@
           <template slot-scope="scope">
             {{ formatDate(scope.row.createdAt) }}
           </template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="180" fixed="right">
           <template slot-scope="{ row }">
@@ -145,10 +152,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getChildrenList, adjustChildPoints, updateChild } from '@/api/admin'
 
 export default {
   name: 'Children',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 340
@@ -159,6 +168,7 @@ export default {
       loading: false,
       submitting: false,
       tableData: [],
+      sortableColumns: { id: 'ID', createdAt: '创建时间' },
       searchForm: {
         nickname: '',
         familyId: ''
@@ -203,6 +213,9 @@ export default {
     this.loadChildren()
   },
   methods: {
+    loadList() {
+      this.loadChildren()
+    },
     handleActionCmd(row, cmd) {
       switch (cmd) {
         case 'adjustPoints': this.handleAdjustPoints(row); break;
@@ -217,6 +230,7 @@ export default {
           size: this.pagination.size,
           ...this.searchForm
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await getChildrenList(params)
         this.tableData = res.data.records || res.data || []
         this.pagination.total = res.data.total || this.tableData.length
@@ -310,4 +324,14 @@ export default {
 </script>
 
 <style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 20 - 0
cfc-web/src/views/Families.vue

@@ -45,6 +45,9 @@
           <template slot-scope="scope">
             {{ formatDate(scope.row.createdAt) }}
           </template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="250" fixed="right">
           <template slot-scope="{ row }">
@@ -282,11 +285,13 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getFamilyList, getFamilyMembers, updateFamily, deleteFamily, setMemberAdminRole } from '@/api/admin'
 import { getCouponList, issueFamilyCoupon, getFamilyCoupons, getFamilyCouponUsage } from '@/api/coupon'
 
 export default {
   name: 'Families',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 340
@@ -297,6 +302,7 @@ export default {
       loading: false,
       submitting: false,
       tableData: [],
+      sortableColumns: { createdAt: '创建时间' },
       searchForm: {
         name: ''
       },
@@ -349,6 +355,9 @@ export default {
     this.loadFamilies()
   },
   methods: {
+    loadList() {
+      this.loadFamilies()
+    },
     handleActionCmd(row, cmd) {
       switch (cmd) {
         case 'viewMembers': this.handleViewMembers(row); break;
@@ -367,6 +376,7 @@ export default {
           size: this.pagination.size,
           ...this.searchForm
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await getFamilyList(params)
         this.tableData = res.data.records || res.data || []
         this.pagination.total = res.data.total || this.tableData.length
@@ -588,4 +598,14 @@ export default {
 </script>
 
 <style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 24 - 2
cfc-web/src/views/GuideFamilyTask.vue

@@ -98,7 +98,11 @@
             <p>暂无任务数据</p>
           </div>
         </template>
-        <el-table-column prop="id" label="ID" width="60"></el-table-column>
+        <el-table-column prop="id" label="ID" width="60">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="title" label="任务标题" min-width="150"></el-table-column>
         <el-table-column prop="points" label="积分" width="80"></el-table-column>
         <el-table-column prop="deadline" label="截止时间" width="160">
@@ -196,10 +200,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { fetchBoundFamilies, fetchFamilyOverview, fetchChildTasks, fetchPendingReviewTasks, reviewTask, updateTask, deleteTask } from '@/api/guideTask'
 
 export default {
   name: 'GuideFamilyTask',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 340
@@ -213,6 +219,7 @@ taskLoading: false,
     families: [],
       children: [],
       tasks: [],
+      sortableColumns: { id: 'ID' },
       familyOverview: null,
       total: 0,
       searchForm: {
@@ -285,6 +292,9 @@ taskLoading: false,
       this.searchForm.memberId = child.memberId
       this.loadChildTasks()
     },
+    loadList() {
+      this.loadChildTasks()
+    },
     async loadChildTasks() {
       if (!this.searchForm.memberId) {
         this.tasks = []
@@ -298,7 +308,8 @@ taskLoading: false,
           this.searchForm.memberId,
           this.pagination.page,
           this.pagination.size,
-          this.searchForm.status
+          this.searchForm.status,
+          this.sortState.length > 0 ? this.sortState : null
         )
         this.tasks = res.data.records || []
         this.total = res.data.total || 0
@@ -439,4 +450,15 @@ taskLoading: false,
 .empty-table-state {
   margin: 20px 0;
 }
+
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 21 - 1
cfc-web/src/views/GuidePackages.vue

@@ -49,6 +49,9 @@
         </el-table-column>
         <el-table-column prop="createdAt" label="创建时间" width="160">
           <template slot-scope="{ row }">{{ formatDate(row.createdAt) }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="280" fixed="right">
           <template slot-scope="{ row }">
@@ -189,10 +192,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getMyPackages, getGuidePackage, getGuidePackageItems, createPackage, updatePackage, submitPackageForReview, publishPackage, addPackageItem, updatePackageItem, deletePackageItem } from '@/api/guidePackage'
 
 export default {
   name: 'GuidePackages',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 340
@@ -203,6 +208,7 @@ export default {
       loading: false,
       saving: false,
       tableData: [],
+      sortableColumns: { createdAt: '创建时间' },
       searchForm: { status: '' },
       dialogVisible: false,
         dialogTitle: '创建任务模板',
@@ -251,10 +257,13 @@ export default {
         case 'share': this.handleShare(row); break;
       }
     },
+    loadList() {
+      this.loadData()
+    },
     async loadData() {
       this.loading = true
       try {
-        const res = await getMyPackages()
+        const res = await getMyPackages(this.sortState.length > 0 ? { sort: this.sortState } : {})
         let data = res.data || []
         if (this.searchForm.status) {
           data = data.filter(p => p.status === this.searchForm.status)
@@ -449,4 +458,15 @@ export default {
   height: 148px;
   display: block;
 }
+
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 22 - 3
cfc-web/src/views/OperationLogs.vue

@@ -60,6 +60,9 @@
           <template slot-scope="scope">
             {{ formatDate(scope.row.createdAt) }}
           </template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">操作时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="80" fixed="right">
           <template slot-scope="{ row }">
@@ -99,11 +102,13 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import axios from '@/utils/request'
 import { getOperationLogs } from '@/api/operationLog'
 
 export default {
   name: 'OperationLogs',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 340
@@ -113,6 +118,7 @@ export default {
     return {
       loading: false,
       logs: [],
+      sortableColumns: { createdAt: '操作时间' },
       filterForm: {
         userId: null,
         operationType: null
@@ -131,6 +137,9 @@ export default {
     this.fetchLogs()
   },
   methods: {
+    loadList() {
+      this.fetchLogs()
+    },
     async fetchLogs() {
       this.loading = true
       try {
@@ -142,6 +151,7 @@ export default {
           startDate: this.dateRange ? this.dateRange[0] : undefined,
           endDate: this.dateRange ? this.dateRange[1] : undefined
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await getOperationLogs(params)
         if (res.code === 200) {
           this.logs = res.data.records || []
@@ -193,9 +203,7 @@ export default {
 <style scoped>
 .operation-logs {
   padding: 20px;
-}
-
-.filter-card {
+}.filter-card {
   margin-bottom: 20px;
 }
 
@@ -207,4 +215,15 @@ export default {
   margin-top: 20px;
   text-align: right;
 }
+
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 20 - 0
cfc-web/src/views/Points.vue

@@ -68,6 +68,9 @@
           <template slot-scope="scope">
             {{ formatDate(scope.row.createdAt) }}
           </template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
       </el-table>
 
@@ -87,10 +90,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getPointsLogs } from '@/api/admin'
 
 export default {
   name: 'Points',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 340
@@ -100,6 +105,7 @@ export default {
     return {
       loading: false,
       tableData: [],
+      sortableColumns: { createdAt: '时间' },
       searchForm: {
         memberId: '',
         type: ''
@@ -115,6 +121,9 @@ export default {
     this.loadPointsLogs()
   },
   methods: {
+    loadList() {
+      this.loadPointsLogs()
+    },
     async loadPointsLogs() {
       this.loading = true
       try {
@@ -123,6 +132,7 @@ export default {
           size: this.pagination.size,
           ...this.searchForm
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await getPointsLogs(params)
         this.tableData = res.data.records || res.data || []
         this.pagination.total = res.data.total || this.tableData.length
@@ -189,4 +199,14 @@ export default {
 </script>
 
 <style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 20 - 0
cfc-web/src/views/Rewards.vue

@@ -93,6 +93,9 @@
               <template slot-scope="scope">
                 {{ formatDate(scope.row.createdAt) }}
               </template>
+              <template slot="header">
+                <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+              </template>
             </el-table-column>
             <el-table-column label="操作" width="200" fixed="right">
               <template slot-scope="{ row }">
@@ -185,10 +188,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getRewardsList, getRewardTemplates, createRewardTemplate, deleteRewardTemplate, approveReward, getFamilyList } from '@/api/admin'
 
 export default {
   name: 'Rewards',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 340
@@ -201,6 +206,7 @@ export default {
       loadingTemplates: false,
       tableData: [],
       templates: [],
+      sortableColumns: { createdAt: '创建时间' },
       searchForm: {
         status: '',
         familyId: '',
@@ -234,6 +240,9 @@ export default {
     this.loadRewards()
   },
   methods: {
+    loadList() {
+      this.loadRewards()
+    },
     handleActionCmd(row, cmd) {
       switch (cmd) {
         case 'approve': this.handleApprove(row, true); break;
@@ -250,6 +259,7 @@ export default {
           status: this.searchForm.status,
           familyId: this.searchForm.familyId || undefined
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await getRewardsList(params)
         this.tableData = res.data.records || res.data || []
         this.pagination.total = res.data.total || this.tableData.length
@@ -423,4 +433,14 @@ export default {
 </script>
 
 <style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 20 - 0
cfc-web/src/views/TaskTemplates.vue

@@ -120,6 +120,9 @@
           <template slot-scope="scope">
             {{ formatDate(scope.row.createdAt) }}
           </template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="180" fixed="right">
           <template slot-scope="scope">
@@ -257,10 +260,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getTaskTemplateList, createTaskTemplate, updateTaskTemplate, deleteTaskTemplate } from '@/api/admin'
 
 export default {
   name: 'TaskTemplates',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 340
@@ -271,6 +276,7 @@ export default {
       loading: false,
       submitting: false,
       tableData: [],
+      sortableColumns: { createdAt: '创建时间' },
       searchForm: {
         category: '',
         difficulty: '',
@@ -326,6 +332,9 @@ export default {
     this.loadTemplates()
   },
   methods: {
+    loadList() {
+      this.loadTemplates()
+    },
     async loadTemplates() {
       this.loading = true
       try {
@@ -334,6 +343,7 @@ export default {
           size: this.pagination.size,
           ...this.searchForm
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await getTaskTemplateList(params)
         this.tableData = res.data.records || res.data || []
         this.pagination.total = res.data.total || this.tableData.length
@@ -511,4 +521,14 @@ export default {
 </script>
 
 <style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 21 - 1
cfc-web/src/views/TeacherConsult.vue

@@ -38,6 +38,9 @@
         </el-table-column>
         <el-table-column prop="createdAt" label="咨询时间" width="160">
           <template slot-scope="{ row }">{{ formatDate(row.createdAt) }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">咨询时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="150" fixed="right">
           <template slot-scope="{ row }">
@@ -87,16 +90,19 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getConsultMessages, replyConsultMessage } from '@/api/teacherMessage'
 
 export default {
   name: 'TeacherConsult',
+  mixins: [SortMixin],
   data() {
     return {
       loading: false,
       replyLoading: false,
       messages: [],
       total: 0,
+      sortableColumns: { createdAt: '咨询时间' },
       searchForm: {
         status: ''
       },
@@ -124,10 +130,13 @@ export default {
     this.loadMessages()
   },
   methods: {
+    loadList() {
+      this.loadMessages()
+    },
     async loadMessages() {
       this.loading = true
       try {
-        const res = await getConsultMessages(this.pagination.page, this.pagination.size, this.searchForm.status)
+        const res = await getConsultMessages(this.pagination.page, this.pagination.size, this.searchForm.status, this.sortState.length > 0 ? this.sortState : null)
         this.messages = res.data.records || []
         this.total = res.data.total || 0
       } catch (e) {
@@ -185,4 +194,15 @@ export default {
   background: #f5f7fa;
   border-radius: 4px;
 }
+
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 21 - 0
cfc-web/src/views/Wishes.vue

@@ -241,6 +241,9 @@
               <template slot-scope="scope">
                 {{ formatDate(scope.row.createdAt) }}
               </template>
+              <template slot="header">
+                <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+              </template>
             </el-table-column>
           </el-table>
 
@@ -277,6 +280,7 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { 
   getWishesList, 
   getPendingWishes, 
@@ -289,12 +293,14 @@ import {
 
 export default {
   name: 'Wishes',
+  mixins: [SortMixin],
   data() {
     return {
       activeTab: 'pending',
       loading: false,
       submitting: false,
       tableData: [],
+      sortableColumns: { createdAt: '创建时间' },
       pendingWishes: [],
       searchForm: {
         status: '',
@@ -346,6 +352,9 @@ export default {
     this.loadPendingWishes()
   },
   methods: {
+    loadList() {
+      this.loadWishes()
+    },
     handleActionCmd(row, cmd) {
       switch (cmd) {
         case 'price': this.showPriceDialog(row); break;
@@ -379,6 +388,7 @@ export default {
           familyId: this.searchForm.familyId || undefined,
           status: this.searchForm.status || undefined
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await getWishesList(params)
         this.tableData = res.data.records || res.data || []
         this.pagination.total = res.data.total || this.tableData.length
@@ -573,4 +583,15 @@ export default {
   color: #999;
   font-size: 12px;
 }
+
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 31 - 3
cfc-web/src/views/admin/Activities.vue

@@ -58,14 +58,22 @@
         <el-button size="mini" @click="resetSearch">重置</el-button>
       </div>
         <el-table :data="activities" v-loading="loading" border stripe style="min-width:900px;" :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="80" />
+        <el-table-column prop="id" label="ID" width="80">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="封面" width="80">
           <template slot-scope="{ row }">
             <img v-if="row.coverImage" :src="row.coverImage" style="width:40px;height:40px;object-fit:cover;border-radius:4px;" />
             <span v-else style="color:#999">无</span>
           </template>
         </el-table-column>
-        <el-table-column prop="title" label="活动标题" min-width="160" show-overflow-tooltip />
+        <el-table-column prop="title" label="活动标题" min-width="160" show-overflow-tooltip>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('title', $event)">活动标题<span v-if="sortIndex('title') !== null" class="sort-index" :class="sortClass('title') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('title') }}</span>{{ sortIcon('title') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="dimensionCode" label="维度" width="100">
           <template slot-scope="{ row }">{{ dimensionLabel(row.dimensionCode) }}</template>
         </el-table-column>
@@ -161,6 +169,7 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getActivityList, publishActivity, endActivity, getRegistrationList, approveRegistration, rejectRegistration } from '@/api/activity'
 
 const STATUS_MAP = {
@@ -182,6 +191,7 @@ const DIMENSION_MAP = {
 }
 
 export default {
+  mixins: [SortMixin],
   name: 'Activities',
   computed: {
     tableHeight() {
@@ -205,7 +215,8 @@ export default {
       regDialogVisible: false,
       regLoading: false,
       currentActivity: null,
-      registrations: []
+      registrations: [],
+      sortableColumns: { id: 'ID', title: '活动标题', createdAt: '创建时间' }
     }
   },
   created() {
@@ -243,6 +254,7 @@ export default {
         if (this.keyword) {
           params.keyword = this.keyword
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await getActivityList(params)
         this.activities = (res.data && res.data.records) || []
         this.total = (res.data && res.data.total) || 0
@@ -299,6 +311,7 @@ export default {
       this.filter.page = 1
       this.loadActivities()
     },
+    loadList() { this.loadActivities() },
   async handlePublish(row) {
     try {
       await this.$confirm('确认发布该活动?', '提示')
@@ -423,4 +436,19 @@ export default {
 .header-search {
   width: 220px;
 }
+
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 23 - 1
cfc-web/src/views/admin/ActivityRegistrationReview.vue

@@ -18,7 +18,11 @@
 
     <div class="table-scroll-wrap-sm">
       <el-table :max-height="tableHeight" :data="filteredList" v-loading="loading" border stripe>
-      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="id" label="ID" width="60">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="registrantName" label="报名人" width="100" />
       <el-table-column prop="registrantPhone" label="手机号" width="120" />
       <el-table-column prop="status" label="状态" width="90">
@@ -29,6 +33,9 @@
         </template>
       </el-table-column>
       <el-table-column prop="registeredAt" label="报名时间" width="160">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('registeredAt', $event)">报名时间<span v-if="sortIndex('registeredAt') !== null" class="sort-index" :class="sortClass('registeredAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('registeredAt') }}</span>{{ sortIcon('registeredAt') }}</span>
+        </template>
         <template slot-scope="{ row }">
           {{ formatTime(row.registeredAt || row.createdAt) }}
         </template>
@@ -56,11 +63,14 @@
 <script>
 import { getActivityList } from '@/api/activity'
 import { getRegistrationList, approveRegistration, rejectRegistration } from '@/api/activity'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'ActivityRegistrationReview',
+  mixins: [SortMixin],
   data() {
     return {
+      sortableColumns: { id: 'ID', registeredAt: '报名时间' },
       activityList: [],
       list: [],
       loading: false,
@@ -107,6 +117,7 @@ export default {
       try {
         const params = { activityId: this.activityId }
         if (this.statusFilter) params.status = this.statusFilter
+        this.applySortToParams(params)
         const res = await getRegistrationList(params)
         if (res.code === 200) this.list = res.data || []
       } catch (e) { console.error(e) }
@@ -149,3 +160,14 @@ export default {
 .header h2 { margin: 0; }
 .filters { display: flex; align-items: center; gap: 12px; }
 </style>
+<style>
+.sort-header { cursor: pointer; user-select: none; }
+.sort-header:hover { color: #409EFF; }
+.sort-asc-badge, .sort-desc-badge {
+  display: inline-block; font-size: 10px; margin-left: 2px;
+  width: 14px; height: 14px; line-height: 14px; text-align: center;
+  border-radius: 3px; color: #fff; vertical-align: middle;
+}
+.sort-asc-badge { background: #409EFF; }
+.sort-desc-badge { background: #E6A23C; }
+</style>

+ 28 - 2
cfc-web/src/views/admin/ActivityReview.vue

@@ -24,10 +24,18 @@
         </div>
       </div>
         <el-table :data="pendingActivities" v-loading="loading" border stripe style="min-width:900px;" :max-height="tableHeight">
-      <el-table-column prop="id" label="ID" width="80" />
+      <el-table-column prop="id" label="ID" width="80">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="title" label="活动标题" min-width="200" show-overflow-tooltip />
       <el-table-column prop="applicant" label="申请人" width="120" />
-      <el-table-column prop="createdAt" label="申请时间" width="180" />
+      <el-table-column prop="createdAt" label="申请时间" width="180">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">申请时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column label="状态" width="100">
         <template slot-scope="{ row }">
           <el-tag v-if="row.status === 'draft'" type="warning">待审核</el-tag>
@@ -69,9 +77,11 @@
 
 <script>
 import { getActivityList, reviewActivity } from '@/api/activity'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'ActivityReview',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -79,6 +89,7 @@ export default {
   },
   data() {
     return {
+      sortableColumns: { id: 'ID', createdAt: '申请时间' },
       loading: false,
       pendingActivities: [],
       filter: {
@@ -95,6 +106,9 @@ export default {
     this.loadPending()
   },
   methods: {
+    loadList() {
+      this.loadPending()
+    },
     async loadPending() {
       this.loading = true
       try {
@@ -105,6 +119,7 @@ export default {
           keyword: this.filter.keyword || undefined,
           dimensionCode: this.filter.dimensionCode || undefined
         }
+        this.applySortToParams(params)
         const res = await getActivityList(params)
         this.pendingActivities = (res.data && res.data.records) || []
         this.total = (res.data && res.data.total) || 0
@@ -161,3 +176,14 @@ export default {
   color: #ccc;
 }
 </style>
+<style>
+.sort-header { cursor: pointer; user-select: none; }
+.sort-header:hover { color: #409EFF; }
+.sort-asc-badge, .sort-desc-badge {
+  display: inline-block; font-size: 10px; margin-left: 2px;
+  width: 14px; height: 14px; line-height: 14px; text-align: center;
+  border-radius: 3px; color: #fff; vertical-align: middle;
+}
+.sort-asc-badge { background: #409EFF; }
+.sort-desc-badge { background: #E6A23C; }
+</style>

+ 28 - 3
cfc-web/src/views/admin/ArticleCategory.vue

@@ -10,13 +10,21 @@
       </div>
       <div class="table-scroll-wrap-sm">
       <el-table :data="filteredData" v-loading="loading" border stripe :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="70" />
+        <el-table-column prop="id" label="ID" width="70">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="图标" width="80">
           <template slot-scope="{ row }">
             <span style="font-size: 22px">{{ row.icon || '📄' }}</span>
           </template>
         </el-table-column>
-        <el-table-column prop="name" label="名称" min-width="140" />
+        <el-table-column prop="name" label="名称" min-width="140">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('name', $event)">名称{{ sortIcon('name') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="颜色" width="100">
           <template slot-scope="{ row }">
             <span v-if="row.color" :style="{ display: 'inline-block', width: '20px', height: '20px', backgroundColor: row.color, borderRadius: '4px', verticalAlign: 'middle', border: '1px solid #ddd' }"></span>
@@ -91,14 +99,17 @@ import {
   adminArticleCategoryUpdate,
   adminArticleCategoryDelete
 } from '@/api/article.js'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'ArticleCategory',
+  mixins: [SortMixin],
   data() {
     return {
       loading: false,
       submitting: false,
       categories: [],
+      sortableColumns: { id: 'ID', name: '名称' },
       dialogVisible: false,
       successDialogVisible: false,
       isEdit: false,
@@ -129,10 +140,15 @@ export default {
     }
   },
   methods: {
+    loadList() {
+      this.loadData()
+    },
     async loadData() {
       this.loading = true
       try {
-        const res = await adminArticleCategoriesList()
+        var params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await adminArticleCategoriesList(params)
         if (res.data) {
           this.categories = res.data
         }
@@ -227,4 +243,13 @@ export default {
 .article-category {
   padding: 20px;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 25 - 2
cfc-web/src/views/admin/AssessmentAssign.vue

@@ -7,7 +7,11 @@
       </div>
 
         <el-table :max-height="tableHeight" :data="list" v-loading="loading" border stripe empty-text="暂无可分配记录">
-          <el-table-column prop="id" label="ID" width="70" />
+          <el-table-column prop="id" label="ID" width="70">
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+            </template>
+          </el-table-column>
           <el-table-column label="用户" width="120">
             <template slot-scope="{ row }">{{ row.userName }}</template>
           </el-table-column>
@@ -29,6 +33,9 @@
             <template slot-scope="{ row }">{{ row.notes || '-' }}</template>
           </el-table-column>
           <el-table-column label="创建时间" width="160">
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+            </template>
             <template slot-scope="{ row }">{{ row.createdAt }}</template>
           </el-table-column>
           <el-table-column label="操作" width="220" fixed="right">
@@ -84,9 +91,11 @@
 
 <script>
 import { getPendingAssessmentList, getAvailableGuides, assignGuide } from '@/api/admin'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'AssessmentAssign',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -94,6 +103,7 @@ export default {
   },
   data() {
     return {
+      sortableColumns: { id: 'ID', createdAt: '创建时间' },
       loading: false,
       list: [],
       page: 1,
@@ -116,7 +126,9 @@ export default {
     async loadList() {
       this.loading = true
       try {
-        const res = await getPendingAssessmentList({ page: this.page, size: this.size })
+        const params = { page: this.page, size: this.size }
+        this.applySortToParams(params)
+        const res = await getPendingAssessmentList(params)
         this.list = res.data.records || []
         this.total = res.data.total || 0
       } catch (e) {
@@ -182,4 +194,15 @@ export default {
   margin-top: 20px;
   text-align: right;
 }
+</style>
+<style>
+.sort-header { cursor: pointer; user-select: none; }
+.sort-header:hover { color: #409EFF; }
+.sort-asc-badge, .sort-desc-badge {
+  display: inline-block; font-size: 10px; margin-left: 2px;
+  width: 14px; height: 14px; line-height: 14px; text-align: center;
+  border-radius: 3px; color: #fff; vertical-align: middle;
+}
+.sort-asc-badge { background: #409EFF; }
+.sort-desc-badge { background: #E6A23C; }
 </style>

+ 22 - 1
cfc-web/src/views/admin/AssessmentOrders.vue

@@ -57,6 +57,9 @@
         </el-table-column>
         <el-table-column label="创建时间" width="155">
           <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="130" fixed="right">
           <template slot-scope="{ row }">
@@ -124,10 +127,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getAssessmentOrderList, changeAssessmentGuide, getAvailableGuides } from '@/api/admin'
 
 export default {
   name: 'AssessmentOrders',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 320
@@ -148,7 +153,8 @@ export default {
       selectedGuideId: null,
       guides: [],
       guidesLoading: false,
-      changing: false
+      changing: false,
+      sortableColumns: { createdAt: '创建时间' }
     }
   },
   mounted() {
@@ -169,6 +175,7 @@ export default {
         if (this.keyword) {
           params.keyword = this.keyword
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         var res = await getAssessmentOrderList(params)
         this.list = res.data.records || []
         this.total = res.data.total || 0
@@ -255,6 +262,20 @@ export default {
   margin-top: 20px;
   text-align: right;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 
 
 </style>

+ 23 - 3
cfc-web/src/views/admin/AssessmentProducts.vue

@@ -27,7 +27,11 @@
       </div>
 
       <el-table :data="list" v-loading="loading" border stripe :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="70" />
+        <el-table-column prop="id" label="ID" width="70">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="商品图片" width="80">
           <template slot-scope="{ row }">
             <img v-if="row.coverImage" :src="row.coverImage" class="product-thumb"
@@ -99,9 +103,11 @@
 
 <script>
 import { getProductList, reviewProduct, shelveProduct } from '@/api/admin'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'AssessmentProducts',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -109,6 +115,7 @@ export default {
   },
   data() {
     return {
+      sortableColumns: { id: 'ID' },
       list: [],
       loading: false,
       page: 1,
@@ -129,13 +136,15 @@ export default {
     async loadList() {
       this.loading = true
       try {
-        const res = await getProductList({
+        const params = {
           page: this.page,
           size: this.size,
           productType: 'assessment',
           keyword: this.filters.keyword || undefined,
           status: this.filters.status || undefined
-        })
+        }
+        this.applySortToParams(params)
+        const res = await getProductList(params)
         if (res.code === 200 && res.data) {
           this.list = res.data.records || []
           this.total = res.data.total || 0
@@ -200,3 +209,14 @@ export default {
 .product-thumb { width: 40px; height: 40px; object-fit: cover; border-radius: 4px; cursor: pointer; }
 .pagination-wrap { margin-top: 16px; text-align: right; }
 </style>
+<style>
+.sort-header { cursor: pointer; user-select: none; }
+.sort-header:hover { color: #409EFF; }
+.sort-asc-badge, .sort-desc-badge {
+  display: inline-block; font-size: 10px; margin-left: 2px;
+  width: 14px; height: 14px; line-height: 14px; text-align: center;
+  border-radius: 3px; color: #fff; vertical-align: middle;
+}
+.sort-asc-badge { background: #409EFF; }
+.sort-desc-badge { background: #E6A23C; }
+</style>

+ 33 - 4
cfc-web/src/views/admin/BadgeManage.vue

@@ -26,7 +26,11 @@
       </el-form>
 
       <el-table :data="list" border stripe v-loading="loading" empty-text="暂无数据" style="width:100%" :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="id" label="ID" width="60">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="name" label="名称" min-width="120" />
         <el-table-column prop="category" label="分类" width="90" />
         <el-table-column label="等级" width="80">
@@ -49,7 +53,12 @@
           </template>
         </el-table-column>
         <el-table-column prop="sortOrder" label="排序" width="60" align="center" />
-        <el-table-column prop="createdAt" label="创建时间" width="160" />
+        <el-table-column prop="createdAt" label="创建时间" width="160">
+          <template slot-scope="{ row }">{{ formatTime ? formatTime(row.createdAt) : row.createdAt }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="操作" width="160" fixed="right">
           <template slot-scope="{ row }">
             <el-button type="text" @click="editRow(row)">编辑</el-button>
@@ -146,9 +155,11 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getBadgeList, createBadge, updateBadge, deleteBadge } from '@/api/badge'
 
 export default {
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -201,7 +212,8 @@ export default {
           _active: true,
           _needApproval: false
         }
-      }
+      },
+      sortableColumns: { id: 'ID', createdAt: '创建时间', name: '名称' }
     }
   },
   mounted() { this.fetchList() },
@@ -213,7 +225,9 @@ export default {
     async fetchList() {
       this.loading = true
       try {
-        const res = await getBadgeList()
+        const params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getBadgeList(params)
         var data = res.data || []
         // Apply client-side filters
         if (this.filters.name) {
@@ -234,6 +248,7 @@ export default {
         this.loading = false
       }
     },
+    loadList() { this.fetchList() },
     showAddDialog() {
       this.dialog.isEdit = false
       this.dialog.form = {
@@ -344,4 +359,18 @@ export default {
 .badge-manage-page {
   padding: 16px;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 27 - 3
cfc-web/src/views/admin/BaziConfigs.vue

@@ -10,7 +10,11 @@
       </div>
       <div class="table-scroll-wrap">
       <el-table :data="filteredData" stripe :max-height="tableHeight"
-        <el-table-column prop="id" label="ID" width="80"></el-table-column>
+        <el-table-column prop="id" label="ID" width="80">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="stemOrBranch" label="天干/地支" width="100"></el-table-column>
         <el-table-column prop="type" label="类型" width="80">
           <template slot-scope="scope">
@@ -101,9 +105,11 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { listBaziConfigs, createBaziConfig, updateBaziConfig, deleteBaziConfig } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   name: 'BaziConfigs',
   data() {
     return {
@@ -113,7 +119,8 @@ export default {
       dialogVisible: false,
       successDialogVisible: false,
       isEdit: false,
-      form: this.getEmptyForm()
+      form: this.getEmptyForm(),
+      sortableColumns: { id: 'ID' }
     }
   },
   created() {
@@ -148,12 +155,15 @@ export default {
     },
     async loadData() {
       try {
-        const res = await listBaziConfigs()
+        const params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await listBaziConfigs(params)
         if (res.data) this.configs = res.data
       } catch (e) {
         console.error(e)
       }
     },
+    loadList() { this.loadData() },
     handleSearch() {
     },
     handleCreate() {
@@ -212,4 +222,18 @@ export default {
 </script>
 
 <style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 27 - 3
cfc-web/src/views/admin/BloodTypeConfigs.vue

@@ -9,7 +9,11 @@
         </div>
       </div>
       <div class="table-scroll-wrap"><el-table :data="filteredData" stripe :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="80"></el-table-column>
+        <el-table-column prop="id" label="ID" width="80">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="bloodType" label="血型" width="80"></el-table-column>
         <el-table-column prop="description" label="性格描述" min-width="200"></el-table-column>
         <el-table-column prop="mindBase" label="心基础分" width="100"></el-table-column>
@@ -79,9 +83,11 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { listBloodTypeConfigs, createBloodTypeConfig, updateBloodTypeConfig, deleteBloodTypeConfig } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   name: 'BloodTypeConfigs',
   data() {
     return {
@@ -91,7 +97,8 @@ export default {
       dialogVisible: false,
       successDialogVisible: false,
       isEdit: false,
-      form: this.getEmptyForm()
+      form: this.getEmptyForm(),
+      sortableColumns: { id: 'ID' }
     }
   },
   created() {
@@ -124,12 +131,15 @@ export default {
     },
     async loadData() {
       try {
-        const res = await listBloodTypeConfigs()
+        const params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await listBloodTypeConfigs(params)
         if (res.data) this.configs = res.data
       } catch (e) {
         console.error(e)
       }
     },
+    loadList() { this.loadData() },
     handleSearch() {
     },
     handleCreate() {
@@ -188,4 +198,18 @@ export default {
 </script>
 
 <style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 29 - 2
cfc-web/src/views/admin/ButlerAssignments.vue

@@ -5,7 +5,11 @@
         <span>管家分配管理</span>
       </div>
       <el-table v-loading="loading" :data="records" border stripe>
-        <el-table-column prop="id" label="ID" width="80" />
+        <el-table-column prop="id" label="ID" width="80">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="familyId" label="家庭ID" width="100" />
         <el-table-column prop="butlerNickname" label="管家" min-width="140" />
         <el-table-column prop="butlerUserId" label="管家用户ID" width="110" />
@@ -16,6 +20,9 @@
         </el-table-column>
         <el-table-column label="绑定时间" width="170">
           <template slot-scope="{ row }">{{ formatTime(row.assignedAt) }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('assignedAt', $event)">绑定时间{{ sortIcon('assignedAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="120" fixed="right">
           <template slot-scope="{ row }">
@@ -37,13 +44,16 @@
 
 <script>
 import { getButlerAssignments, unassignButler } from '@/api/butler'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'ButlerAssignments',
+  mixins: [SortMixin],
   data() {
     return {
       loading: false,
       records: [],
+      sortableColumns: { id: 'ID', assignedAt: '绑定时间' },
       total: 0,
       pageNum: 1,
       pageSize: 10
@@ -53,13 +63,18 @@ export default {
     this.loadData()
   },
   methods: {
+    loadList() {
+      this.loadData()
+    },
     formatTime(iso) {
       if (!iso) return '-'
       return iso.substring(0, 19).replace('T', ' ')
     },
     loadData() {
       this.loading = true
-      getButlerAssignments({ pageNum: this.pageNum, pageSize: this.pageSize })
+      var params = { pageNum: this.pageNum, pageSize: this.pageSize }
+      if (this.sortState.length > 0) params.sort = this.sortState
+      getButlerAssignments(params)
         .then(res => {
           const data = res.data || {}
           this.records = data.records || []
@@ -85,3 +100,15 @@ export default {
   }
 }
 </script>
+
+<style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+</style>

+ 40 - 6
cfc-web/src/views/admin/CfValueManage.vue

@@ -11,7 +11,11 @@
     <el-tabs v-model="activeTab">
       <el-tab-pane label="余额列表" name="balances">
         <el-table :data="balanceList" v-loading="loadingBalance" border stripe>
-          <el-table-column prop="id" label="ID" width="70" />
+          <el-table-column prop="id" label="ID" width="70">
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+            </template>
+          </el-table-column>
           <el-table-column label="用户" width="140">
             <template slot-scope="{ row }">{{ row.userName || '用户' + row.userId }}</template>
           </el-table-column>
@@ -36,7 +40,11 @@
           <el-button type="primary" @click="searchLogs">查询流水</el-button>
         </div>
         <el-table :data="logList" v-loading="loadingLog" border stripe>
-          <el-table-column prop="id" label="ID" width="70" />
+          <el-table-column prop="id" label="ID" width="70">
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+            </template>
+          </el-table-column>
           <el-table-column label="用户" width="120">
             <template slot-scope="{ row }">{{ row.userName || '用户' + row.userId }}</template>
           </el-table-column>
@@ -50,7 +58,11 @@
           <el-table-column prop="refType" label="来源" width="130" />
           <el-table-column prop="refId" label="关联ID" width="80" />
           <el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip />
-          <el-table-column prop="createdAt" label="时间" width="160" />
+          <el-table-column prop="createdAt" label="时间" width="160">
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+            </template>
+          </el-table-column>
         </el-table>
         <el-pagination v-if="logTotal > 0" :current-page="logPage" :page-size="logSize" :total="logTotal" layout="prev, pager, next" @current-change="pageLogs" />
       </el-tab-pane>
@@ -59,14 +71,17 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getCfValueBalance, getCfValueLogs, getCfValueAllBalances, searchUsers } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   data() {
     return {
       activeTab: 'balances',
       searchUserId: '',
       logUserId: '',
+      sortableColumns: { id: 'ID', createdAt: '时间' },
       // Balances
       balanceList: [],
       loadingBalance: false,
@@ -116,9 +131,13 @@ export default {
       const map = { earn: 'success', spend: 'warning', freeze: 'info', unfreeze: 'info', withdraw: 'primary' }
       return map[type] || 'info'
     },
+    loadList() { this.loadBalances() },
+    loadData() { this.searchLogs() },
     loadBalances() {
       this.loadingBalance = true
-      getCfValueAllBalances({ page: this.balancePage, size: this.balanceSize }).then(async res => {
+      const params = { page: this.balancePage, size: this.balanceSize }
+      if (this.sortState.length > 0) params.sort = this.sortState
+      getCfValueAllBalances(params).then(async res => {
         if (res.code === 200) {
           this.balanceList = res.data.records || []
           this.balanceTotal = res.data.total || 0
@@ -150,7 +169,9 @@ getCfValueBalance({ userId: this.searchUserId }).then(async res => {
       if (!this.logUserId) return
       this.loadingLog = true
       this.logPage = 1
-getCfValueLogs({ userId: this.logUserId, page: 1, size: this.logSize }).then(async res => {
+      const params = { userId: this.logUserId, page: 1, size: this.logSize }
+      if (this.sortState.length > 0) params.sort = this.sortState
+      getCfValueLogs(params).then(async res => {
           if (res.code === 200) {
             this.logList = res.data.records || []
             this.logTotal = res.data.total || 0
@@ -164,4 +185,17 @@ getCfValueLogs({ userId: this.logUserId, page: 1, size: this.logSize }).then(asy
     }
   }
 }
-</script>
+</script>
+
+<style scoped>
+.cf-value { padding: 20px; }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+</style>

+ 23 - 2
cfc-web/src/views/admin/CommentReview.vue

@@ -14,7 +14,11 @@
       </div>
     </div>
     <el-table :data="list" v-loading="loading" border stripe>
-      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="id" label="ID" width="60">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)">ID{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column label="用户" width="120">
         <template slot-scope="{ row }">
           <span>{{ row.userName || '未知' }}</span>
@@ -37,7 +41,11 @@
           </el-tag>
         </template>
       </el-table-column>
-      <el-table-column prop="createdAt" label="时间" width="160" />
+      <el-table-column prop="createdAt" label="时间" width="160">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">时间{{ sortIcon('createdAt') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column label="操作" width="200" fixed="right">
         <template slot-scope="{ row }">
           <el-button size="mini" type="success" @click="audit(row.id, 'approved')" :disabled="row.status !== 'pending'">通过</el-button>
@@ -67,10 +75,13 @@
 <script>
 import { adminCommentList } from '@/api/article'
 import request from '@/utils/request'
+import SortMixin from '@/mixins/SortMixin'
 export default {
+  mixins: [SortMixin],
   data() {
     return {
       list: [],
+      sortableColumns: { id: 'ID', createdAt: '时间' },
       loading: false,
       total: 0,
       listQuery: { status: 'pending', articleId: '', page: 1, size: 20 },
@@ -87,6 +98,7 @@ export default {
         var params = { page: this.listQuery.page, size: this.listQuery.size }
         if (this.listQuery.status) params.status = this.listQuery.status
         if (this.listQuery.articleId) params.articleId = parseInt(this.listQuery.articleId)
+        if (this.sortState.length > 0) params.sort = this.sortState
         var res = await adminCommentList(params)
         if (res.code === 200) {
           this.list = res.data.records || []
@@ -130,4 +142,13 @@ export default {
 <style scoped>
 .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
 .filters { display: flex; gap: 10px; align-items: center; }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 28 - 4
cfc-web/src/views/admin/CouponGrantLog.vue

@@ -29,7 +29,12 @@
           <el-table-column prop="source" label="触发来源" min-width="160" show-overflow-tooltip>
             <template slot-scope="{ row }">{{ row.source || '-' }}</template>
           </el-table-column>
-          <el-table-column prop="createdAt" label="发放时间" width="180" />
+          <el-table-column prop="createdAt" label="发放时间" width="180">
+            <template slot-scope="{ row }">{{ row.createdAt }}</template>
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">发放时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+            </template>
+          </el-table-column>
         </el-table>
       </div>
 
@@ -46,10 +51,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getFamilyCouponGrantLog } from '@/api/coupon'
 import { getFamilyDetail } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   name: 'CouponGrantLog',
   computed: {
     tableHeight() {
@@ -64,7 +71,8 @@ export default {
       size: 20,
       total: 0,
       familyId: '',
-      couponId: ''
+      couponId: '',
+      sortableColumns: { createdAt: '发放时间', id: 'ID' }
     }
   },
   created() {
@@ -82,12 +90,14 @@ export default {
     async loadData() {
       this.loading = true
       try {
-        const res = await getFamilyCouponGrantLog({
+        const params = {
           page: this.page,
           size: this.size,
           familyId: this.familyId || undefined,
           couponId: this.couponId || undefined
-        })
+        }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getFamilyCouponGrantLog(params)
         if (res.data) {
           this.list = res.data
           this.total = this.list.length
@@ -136,4 +146,18 @@ export default {
 
 <style scoped>
 .coupon-grant-log { padding: 20px; }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 39 - 6
cfc-web/src/views/admin/DanExecution.vue

@@ -59,6 +59,9 @@
         </el-table-column>
         <el-table-column label="创建时间" width="160">
           <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="160" fixed="right">
           <template slot-scope="{ row }">
@@ -151,9 +154,11 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { searchUsers } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   name: 'DanExecution',
   data() {
     return {
@@ -165,7 +170,8 @@ export default {
       statusFilter: '',
       detailDialogVisible: false,
       detailLoading: false,
-      currentRecord: null
+      currentRecord: null,
+      sortableColumns: { id: 'ID', createdAt: '创建时间' }
     }
   },
   created() {
@@ -175,12 +181,14 @@ export default {
     async loadData() {
       this.loading = true
       try {
+        const params = {
+          page: this.page,
+          size: this.size,
+          status: this.statusFilter
+        }
+        if (this.sortState.length > 0) params.sort = this.sortState
         // TODO: 调用后端 API 获取执行记录列表
-        // const res = await this.$axios.post('/api/admin/dan-execution/list', {
-        //   page: this.page,
-        //   size: this.size,
-        //   status: this.statusFilter
-        // })
+        // const res = await this.$axios.post('/api/admin/dan-execution/list', params)
         // this.list = res.data.data.records
         // this.total = res.data.data.total
         this.list = []
@@ -192,6 +200,7 @@ export default {
         this.loading = false
       }
     },
+    loadList() { this.loadData() },
     async resolveUserNames(items, idField) {
       if (!items || items.length === 0) return
       var ids = []
@@ -297,6 +306,15 @@ export default {
 .admin-page {
   padding: 20px;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 
 .admin-page-header {
   display: flex;
@@ -328,4 +346,19 @@ export default {
   margin-top: 20px;
   text-align: right;
 }
+
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 40 - 4
cfc-web/src/views/admin/DanSettlement.vue

@@ -115,6 +115,9 @@
         <el-table-column label="交易ID" prop="transactionId" min-width="150" show-overflow-tooltip />
         <el-table-column label="创建时间" width="160">
           <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="150" fixed="right">
           <template slot-scope="{ row }">
@@ -186,8 +189,11 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 export default {
+  mixins: [SortMixin],
   name: 'DanSettlement',
+  mixins: [SortMixin],
   data() {
     return {
       list: [],
@@ -207,7 +213,9 @@ export default {
       },
       detailDialogVisible: false,
       detailLoading: false,
-      currentRecord: null
+      currentRecord: null,
+      sortableColumns: { id: 'ID', createdAt: '创建时间' },
+      sortableColumns: { createdAt: '创建时间', id: 'ID' }
     }
   },
   created() {
@@ -218,10 +226,12 @@ export default {
     async loadData() {
       this.loading = true
       try {
-        const res = await this.$axios.post('/api/admin/dan-settlement/list', {
+        const params = {
           status: this.statusFilter,
           providerType: this.providerTypeFilter
-        })
+        }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await this.$axios.post('/api/admin/dan-settlement/list', params)
         if (res.data.code === 200) {
           this.list = res.data.data || []
           this.total = this.list.length
@@ -235,6 +245,7 @@ export default {
         this.loading = false
       }
     },
+    loadList() { this.loadData() },
     async loadStats() {
       try {
         const res = await this.$axios.post('/api/admin/dan-settlement/stats')
@@ -245,7 +256,8 @@ export default {
         console.error('加载统计数据失败', e)
       }
     },
-    async viewDetail(row) {
+      loadList() { this.loadData() },
+      async viewDetail(row) {
       this.detailDialogVisible = true
       this.detailLoading = true
       try {
@@ -333,6 +345,15 @@ export default {
 .admin-page {
   padding: 20px;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 
 .admin-page-header {
   display: flex;
@@ -428,4 +449,19 @@ export default {
   margin-top: 20px;
   text-align: right;
 }
+
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 32 - 4
cfc-web/src/views/admin/EcomSupplierManage.vue

@@ -10,8 +10,16 @@
 
     <div class="table-scroll-wrap">
     <el-table :data="list" v-loading="loading" border stripe :max-height="tableHeight">
-      <el-table-column prop="id" label="ID" width="60" />
-      <el-table-column prop="name" label="供应商名称" min-width="160" />
+      <el-table-column prop="id" label="ID" width="60">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column prop="name" label="供应商名称" min-width="160">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('name', $event)">供应商名称<span v-if="sortIndex('name') !== null" class="sort-index" :class="sortClass('name') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('name') }}</span>{{ sortIcon('name') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="contactName" label="联系人" width="120" />
       <el-table-column prop="contactPhone" label="联系电话" width="140" />
       <el-table-column label="启用状态" width="100">
@@ -65,9 +73,11 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getEcomSupplierList, saveEcomSupplier, updateEcomSupplier, deleteEcomSupplier } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   data() {
     return {
       list: [],
@@ -86,11 +96,13 @@ export default {
         status: 1,
         remark: ''
       },
+      sortableColumns: { name: '供应商名称', createdAt: '创建时间', id: 'ID' },
       rules: {
         name: [{ required: true, message: '请输入供应商名称', trigger: 'blur' }],
         contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }],
         contactPhone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }]
-      }
+      },
+      sortableColumns: { id: 'ID', name: '供应商名称', createdAt: '创建时间' }
     }
   },
   computed: {
@@ -108,7 +120,9 @@ export default {
     async loadList() {
       this.loading = true
       try {
-        const res = await getEcomSupplierList({ page: this.page, size: this.size, keyword: this.keyword })
+        const params = { page: this.page, size: this.size, keyword: this.keyword }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getEcomSupplierList(params)
         this.list = res.data.records || res.data || []
         this.total = res.data.total || 0
       } catch (e) {
@@ -204,4 +218,18 @@ export default {
 .ecom-supplier-manage {
   padding: 20px;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 22 - 1
cfc-web/src/views/admin/EmotionAlertManage.vue

@@ -35,6 +35,9 @@
         <template slot-scope="{ row }">
           {{ formatTime(row.createdAt) }}
         </template>
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">@dblclick.stop="onSortToggle('createdAt')">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+        </template>
       </el-table-column>
       <el-table-column label="处理时间" width="160">
         <template slot-scope="{ row }">
@@ -67,10 +70,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getEmotionAlertList, resolveEmotionAlert } from '@/api/admin'
 
 export default {
   name: 'EmotionAlertManage',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -84,7 +89,8 @@ export default {
       size: 20,
       total: 0,
       keyword: '',
-      resolvedFilter: ''
+      resolvedFilter: '',
+      sortableColumns: { createdAt: '创建时间' }
     }
   },
   mounted() {
@@ -101,6 +107,7 @@ export default {
         if (this.keyword) {
           params.keyword = this.keyword
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         var res = await getEmotionAlertList(params)
         this.list = res.data && res.data.list ? res.data.list : []
         this.total = res.data && res.data.total ? res.data.total : 0
@@ -183,4 +190,18 @@ export default {
 .resolved-text {
   color: #909399;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 21 - 3
cfc-web/src/views/admin/EnergyBehaviorConfig.vue

@@ -13,7 +13,11 @@
     </div>
 
     <el-table :data="list" v-loading="loading" border stripe highlight-current-row>
-      <el-table-column prop="behaviorCode" label="行为编码" width="160" />
+      <el-table-column prop="behaviorCode" label="行为编码" width="160">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('behaviorCode', $event)">行为编码<span v-if="sortIndex('behaviorCode') !== null" class="sort-index" :class="sortClass('behaviorCode') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('behaviorCode') }}</span>{{ sortIcon('behaviorCode') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="behaviorName" label="行为名称" width="130" />
       <el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
       <el-table-column label="默认值" width="80">
@@ -128,6 +132,7 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getEnergyBehaviorList, updateEnergyBehavior, toggleEnergyBehavior } from '@/api/energy'
 
 const DIMENSION_MAP = {
@@ -139,6 +144,7 @@ const DIMENSION_MAP = {
 }
 
 export default {
+  mixins: [SortMixin],
   name: 'EnergyBehaviorConfig',
   data() {
     return {
@@ -149,7 +155,8 @@ export default {
       dialogVisible: false,
       form: {},
       dimAssignList: [],
-      dimensions: Object.entries(DIMENSION_MAP).map(([code, v]) => ({ code, name: v.name }))
+      dimensions: Object.entries(DIMENSION_MAP).map(([code, v]) => ({ code, name: v.name })),
+      sortableColumns: { behaviorCode: '行为编码', createdAt: '创建时间' }
     }
   },
   mounted() {
@@ -159,7 +166,9 @@ export default {
     async loadList() {
       this.loading = true
       try {
-        const res = await getEnergyBehaviorList()
+        const params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getEnergyBehaviorList(params)
         if (res.code === 200) {
           this.list = (res.data || []).filter(r => {
             if (this.keyword) {
@@ -234,4 +243,13 @@ export default {
 <style scoped>
 .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
 .filters { display: flex; gap: 10px; align-items: center; }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 21 - 2
cfc-web/src/views/admin/EnergyRuleManagement.vue

@@ -13,7 +13,11 @@
       </div>
     <el-table :max-height="tableHeight" :data="list" v-loading="loading" border stripe>
       <el-table-column prop="id" label="ID" width="60" />
-      <el-table-column prop="ruleCode" label="规则编码" width="140" />
+      <el-table-column prop="ruleCode" label="规则编码" width="140">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('ruleCode', $event)">规则编码<span v-if="sortIndex('ruleCode') !== null" class="sort-index" :class="sortClass('ruleCode') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('ruleCode') }}</span>{{ sortIcon('ruleCode') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="ruleName" label="规则名称" min-width="160" show-overflow-tooltip />
       <el-table-column prop="eventType" label="事件类型" width="120" />
       <el-table-column prop="dimensionCode" label="维度" width="80" />
@@ -98,10 +102,13 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getEnergyRuleList, createEnergyRule, updateEnergyRule, deleteEnergyRule, toggleEnergyRule, getEnergyDimensions } from '@/api/energy'
 
 export default {
+  mixins: [SortMixin],
   name: 'EnergyRuleManagement',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -119,7 +126,9 @@ export default {
       dimensionFilter: '',
       dialogVisible: false,
       saving: false,
-      form: this.getEmptyForm()
+      form: this.getEmptyForm(),
+      sortableColumns: { id: 'ID', ruleCode: '规则编码', createdAt: '创建时间' },
+      sortableColumns: { ruleCode: '规则编码', createdAt: '创建时间', id: 'ID' }
     }
   },
   mounted() {
@@ -142,6 +151,7 @@ export default {
         const params = { page: this.page, size: this.size }
         if (this.dimensionFilter) params.dimensionCode = this.dimensionFilter
         if (this.keyword) params.keyword = this.keyword
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await getEnergyRuleList(params)
         if (res.code === 200 && res.data) {
           this.list = res.data.records || []
@@ -217,4 +227,13 @@ export default {
 .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
 .header h2 { margin: 0; }
 .filters { display: flex; align-items: center; gap: 12px; }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 29 - 3
cfc-web/src/views/admin/FamilyEarnings.vue

@@ -10,7 +10,11 @@
 
     <div class="table-scroll-wrap">
       <el-table :data="list" v-loading="loading" border stripe :max-height="tableHeight" @row-click="showDetail">
-        <el-table-column prop="id" label="ID" width="70" />
+        <el-table-column prop="id" label="ID" width="70">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">@dblclick.stop="onSortToggle('id')">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="familyId" label="家庭ID" width="100" />
         <el-table-column label="累计推荐收入" width="140">
           <template slot-scope="{ row }">
@@ -36,6 +40,9 @@
           <template slot-scope="{ row }">
             {{ row.updatedAt ? formatTime(row.updatedAt) : '-' }}
           </template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('updatedAt', $event)">@dblclick.stop="onSortToggle('updatedAt')">更新时间<span v-if="sortIndex('updatedAt') !== null" class="sort-index" :class="sortClass('updatedAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('updatedAt') }}</span>{{ sortIcon('updatedAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="100" fixed="right">
           <template slot-scope="{ row }">
@@ -92,9 +99,11 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getFamilyEarningsList, getFamilyEarningsDetail } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -107,7 +116,8 @@ export default {
       searchKeyword: '',
       detailVisible: false,
       detailLoading: false,
-      detail: null
+      detail: null,
+      sortableColumns: { id: 'ID', updatedAt: '更新时间' }
     }
   },
   mounted() {
@@ -117,7 +127,9 @@ export default {
     async loadList() {
       this.loading = true
       try {
-        var res = await getFamilyEarningsList()
+        var params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        var res = await getFamilyEarningsList(params)
         this.list = res.data || []
       } catch (e) {
         this.$message.error('加载失败')
@@ -164,4 +176,18 @@ export default {
   font-weight: bold;
   color: #67c23a;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 24 - 2
cfc-web/src/views/admin/FamilyEarningsWithdraw.vue

@@ -37,6 +37,9 @@
           <template slot-scope="{ row }">
             {{ row.createdAt ? formatTime(row.createdAt) : '-' }}
           </template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">@dblclick.stop="onSortToggle('createdAt')">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
       </el-table>
     </div>
@@ -53,9 +56,11 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getFamilyEarningsRecords } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -68,7 +73,8 @@ export default {
       page: 1,
       size: 20,
       total: 0,
-      typeFilter: ''
+      typeFilter: '',
+      sortableColumns: { createdAt: '创建时间' }
     }
   },
   mounted() {
@@ -78,7 +84,9 @@ export default {
     async loadList() {
       this.loading = true
       try {
-        var res = await getFamilyEarningsRecords(0, this.typeFilter)
+        var params = { type: this.typeFilter }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        var res = await getFamilyEarningsRecords(0, params)
         if (res.data) {
           this.list = Array.isArray(res.data) ? res.data : [res.data]
           this.total = this.list.length
@@ -127,4 +135,18 @@ export default {
   font-weight: bold;
   color: #67c23a;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 12 - 2
cfc-web/src/views/admin/FileManage.vue

@@ -80,7 +80,12 @@
           </template>
         </el-table-column>
         <el-table-column prop="description" label="描述" min-width="120" show-overflow-tooltip></el-table-column>
-        <el-table-column prop="createdAt" label="上传时间" width="160"></el-table-column>
+        <el-table-column prop="createdAt" label="上传时间" width="160">
+          <template slot-scope="scope">{{ scope.row.createdAt }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">上传时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="操作" width="120" fixed="right">
           <template slot-scope="scope">
             <el-button size="mini" type="text" @click="openEdit(scope.row)">编辑</el-button>
@@ -118,20 +123,24 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { listFiles, updateFile, deleteFile, getFileTags, getFileStats } from '@/api/file'
 
 export default {
   name: 'FileManage',
+  mixins: [SortMixin],
   data() {
     return {
       tableData: [], total: 0, loading: false, stats: {}, tagOptions: [], dateRange: null,
       query: { page: 1, pageSize: 20, category: '', fileType: '', tagIds: [], search: '', dateFrom: '', dateTo: '' },
       editVisible: false, saving: false,
-      editForm: { id: null, url: '', originalFilename: '', description: '', tagIds: [] }
+      editForm: { id: null, url: '', originalFilename: '', description: '', tagIds: [] },
+      sortableColumns: { createdAt: '上传时间' }
     }
   },
   mounted() { this.loadStats(); this.loadTags(); this.loadData() },
   methods: {
+    loadList() { this.loadData() },
     async loadData() {
       this.loading = true
       try {
@@ -139,6 +148,7 @@ export default {
           this.query.dateFrom = this.dateRange[0].toISOString().slice(0, 10)
           this.query.dateTo = this.dateRange[1].toISOString().slice(0, 10)
         } else { this.query.dateFrom = ''; this.query.dateTo = '' }
+        if (this.sortState.length > 0) this.query.sort = this.sortState
         const res = await listFiles(this.query)
         if (res.data.code === 200) { this.tableData = res.data.data.records; this.total = res.data.data.total }
       } catch (e) { this.$message.error('加载失败') }

+ 29 - 3
cfc-web/src/views/admin/GrowthTaskManagement.vue

@@ -21,8 +21,16 @@
         </div>
       </div>
       <el-table :max-height="tableHeight" :data="tasks" v-loading="loading" border stripe>
-        <el-table-column prop="id" label="ID" width="80" />
-        <el-table-column prop="name" label="任务名称" min-width="150" show-overflow-tooltip />
+        <el-table-column prop="id" label="ID" width="80">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">@dblclick.stop="onSortToggle('id')">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="name" label="任务名称" min-width="150" show-overflow-tooltip>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('name', $event)">@dblclick.stop="onSortToggle('name')">任务名称<span v-if="sortIndex('name') !== null" class="sort-index" :class="sortClass('name') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('name') }}</span>{{ sortIcon('name') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="dimensionCode" label="关联维度" width="100">
           <template slot-scope="{ row }">
             <el-tag size="mini" :type="dimensionType(row.dimensionCode)">{{ dimensionLabel(row.dimensionCode) }}</el-tag>
@@ -158,6 +166,7 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
 import { getGrowthTaskList, createGrowthTask, updateGrowthTask } from '@/api/growthTask'
 import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weight.js'
@@ -173,6 +182,7 @@ const DIMENSION_CONFIG = {
 export default {
   name: 'GrowthTaskManagement',
   components: { DimensionWeightPicker },
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -192,7 +202,8 @@ export default {
         page: 1,
         size: 10
       },
-      form: {}
+      form: {},
+      sortableColumns: { id: 'ID', name: '任务名称' }
     }
   },
   methods: {
@@ -231,6 +242,7 @@ export default {
         if (this.keyword) {
           params.keyword = this.keyword
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await getTaskList(params)
         this.tasks = (res.data && res.data.records) || []
         this.total = (res.data && res.data.total) || 0
@@ -356,4 +368,18 @@ export default {
 
 <style scoped>
 .growth-task { padding: 20px; }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 25 - 3
cfc-web/src/views/admin/HealthCheckinManage.vue

@@ -45,6 +45,9 @@
         <el-table-column label="备注" min-width="160" show-overflow-tooltip prop="note" />
         <el-table-column label="打卡时间" width="160">
           <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">@dblclick.stop="onSortToggle('createdAt')">打卡时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
       </el-table>
     </el-card>
@@ -52,16 +55,19 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import axios from 'axios'
 
 export default {
   name: 'HealthCheckinManage',
+  mixins: [SortMixin],
   data() {
     return {
       loading: false,
       list: [],
       keyword: '',
-      moodFilter: ''
+      moodFilter: '',
+      sortableColumns: { createdAt: '打卡时间' }
     }
   },
   computed: {
@@ -96,10 +102,12 @@ export default {
       try {
         var baseURL = process.env.VUE_APP_BASE_API || ''
         var token = localStorage.getItem('token')
-        var res = await axios.post(baseURL + '/api/health/checkin/list', {
+        var data = {
           keyword: this.keyword,
           mood: this.moodFilter
-        }, {
+        }
+        if (this.sortState.length > 0) data.sort = this.sortState
+        var res = await axios.post(baseURL + '/api/health/checkin/list', data, {
           headers: { Authorization: 'Bearer ' + token }
         })
         this.list = (res.data && res.data.data) || []
@@ -121,4 +129,18 @@ export default {
   display: flex;
   align-items: center;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 25 - 3
cfc-web/src/views/admin/HealthPlanManage.vue

@@ -42,6 +42,9 @@
       </el-table-column>
       <el-table-column prop="createdAt" label="创建时间" width="160">
         <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">@dblclick.stop="onSortToggle('createdAt')">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+        </template>
       </el-table-column>
       <el-table-column label="操作" width="100">
         <template slot-scope="{ row }">
@@ -112,10 +115,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { adminListHealthPlans, adminGetHealthPlanDetail } from '@/api/healthPlan'
 
 export default {
   name: 'HealthPlanManage',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 280
@@ -134,7 +139,8 @@ export default {
       currentPlan: null,
       detailLoading: false,
       tasks: [],
-      tasksLoading: false
+      tasksLoading: false,
+      sortableColumns: { createdAt: '创建时间', id: '方案ID' }
     }
   },
   created() {
@@ -144,12 +150,14 @@ export default {
     async loadPlans() {
       this.loading = true
       try {
-        const res = await adminListHealthPlans({
+        var params = {
           page: this.pageNum,
           size: this.pageSize,
           keyword: this.keyword || null,
           status: this.statusFilter || null
-        })
+        }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await adminListHealthPlans(params)
         if (res.code === 200) {
           this.plans = res.data?.records || []
           this.total = res.data?.total || 0
@@ -241,4 +249,18 @@ export default {
   white-space: pre-wrap;
   font-size: 14px;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 34 - 9
cfc-web/src/views/admin/InventoryManage.vue

@@ -42,7 +42,12 @@
             <el-table-column prop="beforeStock" label="变动前" width="70" />
             <el-table-column prop="afterStock" label="变动后" width="70" />
             <el-table-column prop="remark" label="备注" min-width="150" show-overflow-tooltip />
-            <el-table-column prop="createdAt" label="时间" width="160" />
+            <el-table-column prop="createdAt" label="时间" width="160">
+              <template slot-scope="{ row }">{{ formatTime ? formatTime(row.createdAt) : row.createdAt }}</template>
+              <template slot="header">
+                <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+              </template>
+            </el-table-column>
           </el-table>
           <el-pagination @size-change="txPageSize=$event;loadTransactions()" @current-change="txPage=$event;loadTransactions()" :current-page="txPage" :page-sizes="[10,20,50]" :page-size="txPageSize" :total="txTotal" layout="total, sizes, prev, pager, next, jumper" class="pagination-wrap" />
         </el-tab-pane>
@@ -218,10 +223,13 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getInventoryTransactions, getInboundOrderList, getInboundOrderDetail, confirmInbound, cancelInbound, getStockAlerts, getCountingList, createCountingRecord, confirmCounting, createManualOutbound, getManualOutboundList, getProductList, getProductSkuList } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   name: 'InventoryManage',
+  mixins: [SortMixin],
   data() {
     return {
       activeTab: 'transactions',
@@ -234,7 +242,8 @@ export default {
       showCreateCounting: false, countingForm: { productId: '', skuId: '', actualStock: '', remark: '' },
       outboundList: [], outboundLoading: false, outboundPage: 1, outboundPageSize: 20, outboundTotal: 0,
       showCreateOutbound: false, outboundProductOptions: [], outboundSkuOptions: [],
-      outboundForm: { productId: '', skuId: '', quantity: 1, reason: '', remark: '' }
+      outboundForm: { productId: '', skuId: '', quantity: 1, reason: '', remark: '' },
+      sortableColumns: { id: 'ID', createdAt: '时间' }
     }
   },
   mounted() {
@@ -245,16 +254,18 @@ export default {
     this.loadOutboundList()
   },
   methods: {
+    loadList() { this.loadTransactions() },
     async loadTransactions() {
       this.txLoading = true
       try {
-        const params = {
-          page: this.txPage, size: this.txPageSize,
-          type: this.txFilters.type || null,
-          startDate: this.txFilters.dateRange ? this.txFilters.dateRange[0] : null,
-          endDate: this.txFilters.dateRange ? this.txFilters.dateRange[1] : null
-        }
-        const res = await getInventoryTransactions(params)
+         const params = {
+           page: this.txPage, size: this.txPageSize,
+           type: this.txFilters.type || null,
+           startDate: this.txFilters.dateRange ? this.txFilters.dateRange[0] : null,
+           endDate: this.txFilters.dateRange ? this.txFilters.dateRange[1] : null
+         }
+         if (this.sortState.length > 0) params.sort = this.sortState
+         const res = await getInventoryTransactions(params)
         this.txList = res.data.records || []
         this.txTotal = res.data.total || 0
       } catch (e) { this.$message.error('加载失败') }
@@ -377,4 +388,18 @@ export default {
 .inventory-manage { padding: 20px; }
 .filter-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
 .pagination-wrap { margin-top: 12px; text-align: right; }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 28 - 3
cfc-web/src/views/admin/KnowledgeTag.vue

@@ -9,7 +9,11 @@
         </div>
       </div>
       <el-table :max-height="tableHeight" :data="filteredTags" v-loading="loading" border stripe>
-        <el-table-column prop="id" label="ID" width="70" />
+        <el-table-column prop="id" label="ID" width="70">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="tagName" label="标签名称" min-width="200" />
         <el-table-column label="类型" width="140">
           <template slot-scope="{ row }">
@@ -18,7 +22,11 @@
             </el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="createdAt" label="创建时间" width="180" />
+        <el-table-column prop="createdAt" label="创建时间" width="180">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间{{ sortIcon('createdAt') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="操作" width="180" fixed="right">
           <template slot-scope="{ row }">
             <el-button size="mini" type="primary" @click="handleEdit(row)">编辑</el-button>
@@ -68,14 +76,17 @@
 
 <script>
 import { getTagList, saveTag, deleteTag } from '@/api/dimension.js'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'KnowledgeTag',
+  mixins: [SortMixin],
   data() {
     return {
       loading: false,
       submitting: false,
       tags: [],
+      sortableColumns: { id: 'ID', createdAt: '创建时间' },
       keyword: '',
       dialogVisible: false,
       successDialogVisible: false,
@@ -101,10 +112,15 @@ export default {
     this.loadData()
   },
   methods: {
+    loadList() {
+      this.loadData()
+    },
     async loadData() {
       this.loading = true
       try {
-        const res = await getTagList()
+        var params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getTagList(params)
         if (res.data) {
           this.tags = res.data
         }
@@ -181,4 +197,13 @@ export default {
 .knowledge-tag {
   padding: 20px;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 10 - 2
cfc-web/src/views/admin/MembershipCenter.vue

@@ -98,6 +98,9 @@
               </el-table-column>
               <el-table-column label="创建时间" width="150">
                 <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+                <template slot="header">
+                  <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+                </template>
               </el-table-column>
             </el-table>
           </div>
@@ -266,6 +269,7 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { searchUsers, updateMembershipUser, updateMembershipLevel, getMembershipStats, listMembershipLevels } from '@/api/admin'
 
 const LEVEL_TAG_TYPE = { FREE: 'info', FAMILY: 'success', PROVIDER: 'warning', PREMIUM: 'primary', LIFETIME: 'danger' }
@@ -274,6 +278,7 @@ const UPGRADE_TYPE_TAG = { pay: 'success', consumption: 'primary', admin: 'warni
 
 export default {
   name: 'MembershipCenter',
+  mixins: [SortMixin],
   data() {
     return {
       activeTab: 'levels',
@@ -326,7 +331,8 @@ export default {
         prioritySupport: 0,
         teacherConsultation: 0,
         features: ''
-      }
+      },
+      sortableColumns: { createdAt: '创建时间', id: 'ID' }
     }
   },
   computed: {
@@ -341,6 +347,7 @@ export default {
     this.refreshAll()
   },
   methods: {
+    loadData() { this.loadUpgradeRecords() },
     async refreshAll() {
       this.overviewLoading = true
       await Promise.all([this.loadOverview(), this.loadLevels(), this.loadUpgradeRecords()])
@@ -394,7 +401,8 @@ export default {
       try {
         const res = await this._request('/api/admin/membership/upgrade-records', {
           page: this.recordFilter.page,
-          size: this.recordFilter.size
+          size: this.recordFilter.size,
+          ...(this.sortState.length > 0 ? { sort: this.sortState } : {})
         })
         if (res.data && res.data.code === 200) {
           const records = (res.data.data && res.data.data.records) || []

+ 25 - 2
cfc-web/src/views/admin/NoticeManage.vue

@@ -29,6 +29,9 @@
           </el-table-column>
           <el-table-column label="创建时间" width="160">
             <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+            </template>
           </el-table-column>
           <el-table-column label="操作" width="140" fixed="right">
             <template slot-scope="{ row }">
@@ -66,10 +69,13 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { adminNoticeList, adminNoticeCreate, adminNoticeUpdate, adminNoticeDelete } from '@/api/notice'
 
 export default {
+  mixins: [SortMixin],
   name: 'NoticeManage',
+  mixins: [SortMixin],
   data() {
     return {
       list: [],
@@ -87,7 +93,8 @@ export default {
         type: [{ required: true, message: '请选择类型', trigger: 'change' }],
         title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
         content: [{ required: true, message: '请输入内容', trigger: 'blur' }]
-      }
+      },
+      sortableColumns: { id: 'ID', createdAt: '创建时间' }
     }
   },
   computed: {
@@ -109,7 +116,9 @@ export default {
     },
     loadList() {
       this.loading = true
-      adminNoticeList({ page: 1, size: 50 }).then(res => {
+      const params = { page: 1, size: 50 }
+      if (this.sortState.length > 0) params.sort = this.sortState
+      adminNoticeList(params).then(res => {
         this.list = res.data || []
         this.loading = false
       }).catch(() => {
@@ -174,4 +183,18 @@ export default {
   justify-content: space-between;
   align-items: center;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 27 - 3
cfc-web/src/views/admin/NotificationTemplateManage.vue

@@ -28,7 +28,12 @@
             </el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="updatedAt" label="更新时间" width="170"></el-table-column>
+        <el-table-column prop="updatedAt" label="更新时间" width="170">
+          <template slot-scope="{ row }">{{ formatTime(row.updatedAt) }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('updatedAt', $event)">更新时间<span v-if="sortIndex('updatedAt') !== null" class="sort-index" :class="sortClass('updatedAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('updatedAt') }}</span>{{ sortIcon('updatedAt') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="操作" width="140" fixed="right">
           <template slot-scope="{ row }">
             <el-button size="mini" type="primary" @click="handleEdit(row)">编辑</el-button>
@@ -82,6 +87,7 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import {
   getNotificationTemplateList,
   saveNotificationTemplate,
@@ -97,6 +103,7 @@ const CHANNEL_MAP = {
 }
 
 export default {
+  mixins: [SortMixin],
   name: 'NotificationTemplateManage',
   data() {
     return {
@@ -112,7 +119,8 @@ export default {
         channels: 'in_app',
         channelList: ['in_app'],
         status: 1
-      }
+      },
+      sortableColumns: { id: 'ID', updatedAt: '更新时间' }
     }
   },
   created() {
@@ -122,7 +130,9 @@ export default {
     async loadData() {
       this.loading = true
       try {
-        const res = await getNotificationTemplateList()
+        const params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getNotificationTemplateList(params)
         if (res.code === 0 || res.code === 200) {
           this.list = res.data || []
         } else {
@@ -210,4 +220,18 @@ export default {
 .notification-template-manage {
   padding: 16px;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 19 - 0
cfc-web/src/views/admin/NutritionProducts.vue

@@ -69,6 +69,9 @@
           <template slot-scope="{ row }">
             <span style="font-weight: 500;">{{ row.name }}</span>
           </template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('name', $event)">产品名称{{ sortIcon('name') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="品牌" width="110" align="center">
           <template slot-scope="{ row }">
@@ -88,6 +91,9 @@
         </el-table-column>
         <el-table-column label="已售" width="70" align="center">
           <template slot-scope="{ row }">{{ row.soldNum != null ? row.soldNum : '-' }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('soldNum', $event)">已售{{ sortIcon('soldNum') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="来源" width="90" align="center">
           <template slot-scope="{ row }">
@@ -238,15 +244,18 @@
 
 <script>
 import { getCategoryList, getBrandList, searchProducts, getProductDetail } from '@/api/nutritionProduct'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'NutritionProducts',
+  mixins: [SortMixin],
   data() {
     return {
       keyword: '',
       categoryFilter: '',
       brandFilter: '',
       list: [],
+      sortableColumns: { name: '产品名称', soldNum: '已售' },
       total: 0,
       page: 1,
       pageSize: 20,
@@ -330,6 +339,7 @@ export default {
         if (this.keyword) params.name = this.keyword
         if (this.categoryFilter) params.categoryId = this.categoryFilter
         if (this.brandFilter) params.brandId = this.brandFilter
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await searchProducts(params)
         const data = res.data || {}
         this.list = data.records || []
@@ -416,4 +426,13 @@ export default {
 .no-data {
   color: #999;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 24 - 3
cfc-web/src/views/admin/PendingRefund.vue

@@ -29,7 +29,11 @@
 
     <div class="table-scroll-wrap">
       <el-table :data="list" v-loading="loading" border stripe :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="70" />
+        <el-table-column prop="id" label="ID" width="70">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="类型" width="100">
           <template slot-scope="{ row }">
             <el-tag :type="row.orderType === 'product' ? 'primary' : 'warning'" size="small">
@@ -81,6 +85,9 @@
           <template slot-scope="{ row }">
             {{ formatTime(row.createdAt) }}
           </template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="退款时间" width="160">
           <template slot-scope="{ row }">
@@ -112,9 +119,11 @@
 
 <script>
 import { getPendingRefundList, getPendingRefundSummary, cancelPendingRefund, searchUsers } from '@/api/admin'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'PendingRefund',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -123,6 +132,7 @@ export default {
   data() {
     return {
       list: [],
+      sortableColumns: { id: 'ID', createdAt: '创建时间' },
       loading: false,
       statusFilter: '',
       typeFilter: '',
@@ -153,12 +163,14 @@ export default {
 async loadList() {
       this.loading = true
       try {
-        const res = await getPendingRefundList({
+        const reqParams = {
           status: this.statusFilter || undefined,
           orderType: this.typeFilter || undefined,
           page: this.currentPage,
           size: this.pageSize
-        })
+        }
+        if (this.sortState.length > 0) reqParams.sort = this.sortState
+        const res = await getPendingRefundList(reqParams)
         if (res.code === 200) {
           this.list = res.data.records || []
           this.total = res.data.total || 0
@@ -244,4 +256,13 @@ async loadList() {
 .summary-bar {
   margin-bottom: 16px;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 28 - 3
cfc-web/src/views/admin/PeriodicServiceConfig.vue

@@ -6,9 +6,17 @@
     </div>
 
     <el-table :data="list" v-loading="loading" border stripe style="width: 100%">
-      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="id" label="ID" width="60">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)">ID{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="productId" label="商品ID" width="80" />
-      <el-table-column prop="serviceName" label="服务名称" min-width="160" />
+      <el-table-column prop="serviceName" label="服务名称" min-width="160">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('serviceName', $event)">服务名称{{ sortIcon('serviceName') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="cycleDays" label="周期天数" width="100" />
       <el-table-column prop="remindBeforeDays" label="提前提醒(天)" width="120" />
       <el-table-column label="自动续费" width="100">
@@ -64,13 +72,16 @@
 
 <script>
 import { getPeriodicServiceList, createPeriodicService, updatePeriodicService, deletePeriodicService, togglePeriodicService } from '@/api/periodic-service'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
+  mixins: [SortMixin],
   data() {
     return {
       submitting: false,
       loading: false,
       list: [],
+      sortableColumns: { id: 'ID', serviceName: '服务名称' },
       dialogVisible: false,
       dialogTitle: '',
       form: {
@@ -88,10 +99,15 @@ export default {
     this.fetchList()
   },
   methods: {
+    loadList() {
+      this.fetchList()
+    },
     async fetchList() {
       this.loading = true
       try {
-        const res = await getPeriodicServiceList()
+        const params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getPeriodicServiceList(params)
         if (res.data && res.data.code === 200) {
           this.list = res.data.data || []
         }
@@ -195,4 +211,13 @@ export default {
   align-items: center;
   margin-bottom: 20px;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 29 - 4
cfc-web/src/views/admin/PlanManagement.vue

@@ -17,10 +17,18 @@
     </div>
 
     <el-table :data="plans" border stripe style="width:100%" :max-height="tableHeight">
-      <el-table-column prop="id" label="方案ID" width="80" />
+      <el-table-column prop="id" label="方案ID" width="80">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)">方案ID{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="familyId" label="家庭ID" width="80" />
       <el-table-column prop="childId" label="孩子ID" width="80" />
-      <el-table-column prop="name" label="方案名称" min-width="180" />
+      <el-table-column prop="name" label="方案名称" min-width="180">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('name', $event)">方案名称{{ sortIcon('name') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column label="状态" width="100">
         <template slot-scope="{ row }">
           <el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag>
@@ -68,8 +76,10 @@
 
 <script>
 import { listFamilyPlans, reviewPlan, activatePlan } from '@/api/plan'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -78,6 +88,7 @@ export default {
   data() {
     return {
       plans: [],
+      sortableColumns: { id: '方案ID', name: '方案名称' },
       statusFilter: '',
       familySearch: '',
       detailVisible: false,
@@ -86,11 +97,16 @@ export default {
   },
   created() { this.loadPlans() },
   methods: {
+    loadList() {
+      this.loadPlans()
+    },
     async loadPlans() {
-      const res = await listFamilyPlans({
+      var params = {
         status: this.statusFilter || null,
         familyId: this.familySearch ? Number(this.familySearch) : null
-      })
+      }
+      if (this.sortState.length > 0) params.sort = this.sortState
+      const res = await listFamilyPlans(params)
       if (res.code === 200) this.plans = res.data || []
     },
     statusType(s) {
@@ -121,4 +137,13 @@ export default {
 <style scoped>
 .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
 .filters { display: flex; align-items: center; }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 22 - 1
cfc-web/src/views/admin/PointsLog.vue

@@ -24,6 +24,9 @@
       <el-table :max-height="tableHeight" :data="list" v-loading="loading" border stripe>
         <el-table-column prop="createdAt" label="时间" width="160">
           <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column prop="childId" label="孩子ID" width="80" />
         <el-table-column label="类型" width="100">
@@ -110,6 +113,7 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getPointsLogs } from '@/api/admin'
 
 const TYPE_MAP = {
@@ -122,6 +126,7 @@ const TYPE_MAP = {
 
 export default {
   name: 'PointsLog',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -140,7 +145,8 @@ export default {
         size: 10
       },
       detailVisible: false,
-      currentRow: null
+      currentRow: null,
+      sortableColumns: { createdAt: '时间' }
     }
   },
   created() {
@@ -167,6 +173,7 @@ export default {
           const childId = parseInt(this.filter.childKeyword)
           if (!isNaN(childId)) params.memberId = childId
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await getPointsLogs(params)
         this.list = (res.data && res.data.records) || []
         this.total = (res.data && res.data.total) || 0
@@ -207,4 +214,18 @@ export default {
 <style scoped>
 .points-log { padding: 20px; }
 .detail-content { padding: 10px 0; }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 33 - 5
cfc-web/src/views/admin/ProblemDomainManage.vue

@@ -7,9 +7,17 @@
       </div>
 
       <el-table :data="list" v-loading="loading" border stripe>
-        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="id" label="ID" width="60">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="code" label="编码" min-width="110" />
-        <el-table-column prop="name" label="名称" min-width="100" />
+        <el-table-column prop="name" label="名称" min-width="100">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('name', $event)">名称<span v-if="sortIndex('name') !== null" class="sort-index" :class="sortClass('name') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('name') }}</span>{{ sortIcon('name') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="icon" label="图标" width="70" align="center">
           <template slot-scope="scope">
             <span style="font-size:20px">{{ scope.row.icon || '🧭' }}</span>
@@ -115,10 +123,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getProblemDomainListAdmin, saveProblemDomain, deleteProblemDomain } from '@/api/problemDomain'
 import { getSurveyTemplates } from '@/api/survey'
 
 export default {
+  mixins: [SortMixin],
   name: 'ProblemDomainManage',
   data() {
     return {
@@ -141,7 +151,8 @@ export default {
         status: 1,
         flowIntroJson: ''
       },
-      flowSteps: []
+      flowSteps: [],
+      sortableColumns: { id: 'ID', name: '名称' }
     }
   },
   created() {
@@ -151,7 +162,9 @@ export default {
   methods: {
     loadList() {
       this.loading = true
-      getProblemDomainListAdmin({}).then(res => {
+      const params = {}
+      if (this.sortState.length > 0) params.sort = this.sortState
+      getProblemDomainListAdmin(params).then(res => {
         this.loading = false
         this.list = res.data || []
       }).catch(() => {
@@ -240,4 +253,19 @@ export default {
     }
   }
 }
-</script>
+<style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
+</style>

+ 26 - 4
cfc-web/src/views/admin/ProductProfitRate.vue

@@ -14,14 +14,22 @@
       </div>
       </div>
     <el-table :max-height="tableHeight" :data="list" v-loading="loading" border stripe>
-      <el-table-column prop="id" label="ID" width="70" />
+      <el-table-column prop="id" label="ID" width="70">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column label="商品图片" width="70">
         <template slot-scope="{ row }">
           <img v-if="row.coverImage" :src="row.coverImage" class="product-thumb" />
           <span v-else>-</span>
         </template>
       </el-table-column>
-      <el-table-column prop="name" label="商品名称" min-width="160" show-overflow-tooltip />
+      <el-table-column prop="name" label="商品名称" min-width="160" show-overflow-tooltip>
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('name', $event)">商品名称<span v-if="sortIndex('name') !== null" class="sort-index" :class="sortClass('name') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('name') }}</span>{{ sortIcon('name') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column label="类型" width="90">
         <template slot-scope="{ row }">
           {{ typeLabel(row.productType) }}
@@ -81,9 +89,11 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getProductList, updateProductProfitRate } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   name: 'ProductProfitRate',
   computed: {
     tableHeight() {
@@ -100,7 +110,8 @@ export default {
       searchText: '',
       typeFilter: '',
       editId: null,
-      editRate: 0
+      editRate: 0,
+      sortableColumns: { id: 'ID', name: '商品名称', createdAt: '创建时间' }
     }
   },
   mounted() {
@@ -110,7 +121,9 @@ export default {
     async loadList() {
       this.loading = true
       try {
-        const res = await getProductList({ status: '', keyword: this.searchText, productType: this.typeFilter })
+        const params = { status: '', keyword: this.searchText, productType: this.typeFilter }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getProductList(params)
         this.list = res.data || []
         this.total = (res.data || []).length
       } catch (e) {
@@ -184,4 +197,13 @@ export default {
 .text-muted {
   color: #999;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 19 - 2
cfc-web/src/views/admin/PromotionManagement.vue

@@ -46,7 +46,11 @@
                 <el-avatar :src="row.avatar" size="small" />
               </template>
             </el-table-column>
-            <el-table-column prop="nickname" label="昵称" min-width="140" show-overflow-tooltip />
+            <el-table-column prop="nickname" label="昵称" min-width="140" show-overflow-tooltip>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('nickname', $event)">昵称<span v-if="sortIndex('nickname') !== null" class="sort-index" :class="sortClass('nickname') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('nickname') }}</span>{{ sortIcon('nickname') }}</span>
+          </template>
+        </el-table-column>
             <el-table-column prop="referralCount" label="邀请数" width="100" />
     </el-table>
     <el-pagination
@@ -83,10 +87,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getMilestoneConfig, updateMilestoneConfig, getLeaderboardTop, getLeaderboardByUser } from '@/api/promotion'
 
 export default {
   name: 'PromotionManagement',
+  mixins: [SortMixin],
   data() {
     return {
       activeTab: 'milestone',
@@ -107,7 +113,8 @@ export default {
       leaderboardPage: 1,
       leaderboardSize: 20,
       leaderboardTotal: 0,
-      searchMode: false
+      searchMode: false,
+      sortableColumns: { nickname: '昵称', createdAt: '创建时间', referralCount: '邀请数' }
     }
   },
   created() {
@@ -192,6 +199,7 @@ export default {
         if (this.weekStart) {
           params.weekStart = this.weekStart
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await getLeaderboardTop(params)
         if (res.data) {
           this.leaderboardList = res.data.records || res.data.list || res.data
@@ -247,4 +255,13 @@ export default {
 .promotion-management {
   padding: 20px;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 24 - 2
cfc-web/src/views/admin/PurchaseManagement.vue

@@ -77,6 +77,9 @@
         </el-table-column>
         <el-table-column prop="createdAt" label="创建时间" width="160">
           <template slot-scope="{ row }">{{ formatDateTime(row.createdAt) }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="120" fixed="right">
           <template slot-scope="{ row }">
@@ -119,9 +122,11 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import request from '@/utils/request'
 
 export default {
+  mixins: [SortMixin],
   name: 'PurchaseManagement',
   data() {
     return {
@@ -138,7 +143,8 @@ export default {
         paidOrders: 0,
         activeUsers: 0,
         revenue: '0.00'
-      }
+      },
+      sortableColumns: { id: 'ID', createdAt: '创建时间' }
     }
   },
   created() {
@@ -149,10 +155,12 @@ export default {
     async loadList() {
       this.loading = true
       try {
+        const params = { page: this.page, size: this.size, ...this.filter }
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await request({
           url: '/api/admin/purchase/list',
           method: 'post',
-          data: { page: this.page, size: this.size, ...this.filter }
+          data: params
         })
         if (res.data && res.data.code === 200) {
           this.list = res.data.data.records || []
@@ -215,4 +223,18 @@ export default {
 .stat-card { text-align: center; }
 .stat-value { font-size: 28rpx; font-weight: bold; color: #F97316; }
 .stat-label { font-size: 12px; color: #999; margin-top: 4px; }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 28 - 3
cfc-web/src/views/admin/ReportAutoLearn.vue

@@ -11,9 +11,17 @@
       <el-alert title='自学习机制:当同一类型的未知报告积累到3份后,系统自动标记为"可生成"状态,管理员可在此生成报告类型和指纹规则。' type='info' :closable='false' show-icon style='margin-bottom: 16px;' />
 
       <el-table :data="clusters" v-loading="loading" border stripe>
-        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="id" label="ID" width="60">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="clusterKey" label="聚类键" width="140" />
-        <el-table-column prop="reportCount" label="报告数" width="80" />
+        <el-table-column prop="reportCount" label="报告数" width="80">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('reportCount', $event)">报告数{{ sortIcon('reportCount') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="状态" width="100">
           <template slot-scope="{ row }">
             <el-tag :type="row.status === 'ready' ? 'warning' : 'success'" size="small">
@@ -52,23 +60,31 @@
 
 <script>
 import { getUnknownClusters, generateTypeFromCluster } from '@/api/reportParser'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'ReportAutoLearn',
+  mixins: [SortMixin],
   data() {
     return {
       loading: false,
       clusters: [],
+      sortableColumns: { id: 'ID', reportCount: '报告数' },
       detailVisible: false,
       currentCluster: null,
     }
   },
   mounted() { this.fetchClusters() },
   methods: {
+    loadList() {
+      this.fetchClusters()
+    },
     async fetchClusters() {
       this.loading = true
       try {
-        const res = await getUnknownClusters({ page: 1, size: 50 })
+        var params = { page: 1, size: 50 }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getUnknownClusters(params)
         this.clusters = (res.data?.records || res.data || []).filter(
           c => c.status === 'ready' || c.status === 'generated')
       } catch (e) { this.$message.error('加载失败') }
@@ -111,4 +127,13 @@ export default {
   white-space: pre-wrap;
   word-break: break-all;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 28 - 3
cfc-web/src/views/admin/ReportCollectorTrainer.vue

@@ -14,9 +14,17 @@
             </el-select>
           </div>
           <el-table :data="clusters" v-loading="loading" border stripe :max-height="tableHeight" @row-click="selectCluster">
-            <el-table-column prop="id" label="ID" width="50" />
+            <el-table-column prop="id" label="ID" width="50">
+              <template slot="header">
+                <span class="sort-header" @click.stop="onSortClick('id', $event)">ID{{ sortIcon('id') }}</span>
+              </template>
+            </el-table-column>
             <el-table-column prop="clusterKey" label="聚类键" width="120" show-overflow-tooltip />
-            <el-table-column prop="reportCount" label="报告数" width="70" />
+            <el-table-column prop="reportCount" label="报告数" width="70">
+              <template slot="header">
+                <span class="sort-header" @click.stop="onSortClick('reportCount', $event)">报告数{{ sortIcon('reportCount') }}</span>
+              </template>
+            </el-table-column>
             <el-table-column label="状态" width="80">
               <template slot-scope="{ row }">
                 <el-tag :type="clusterStatusType(row.status)" size="small">{{ clusterStatusText(row.status) }}</el-tag>
@@ -119,9 +127,11 @@
 <script>
 import { getUnknownClusters, getClusterUploads, generateTypeFromCluster } from '@/api/reportParser'
 import { getReportTemplates } from '@/api/reportTemplate'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'ReportCollectorTrainer',
+  mixins: [SortMixin],
   data() {
     return {
       loading: false,
@@ -130,6 +140,7 @@ export default {
       savingAnnotation: false,
       statusFilter: '',
       clusters: [],
+      sortableColumns: { id: 'ID', reportCount: '报告数' },
       currentCluster: null,
       uploads: [],
       selectedUpload: null,
@@ -147,9 +158,14 @@ export default {
   },
   mounted() { this.fetchClusters() },
   methods: {
+    loadList() {
+      this.fetchClusters()
+    },
     fetchClusters() {
       this.loading = true
-      getUnknownClusters({ page: 1, size: 100, status: this.statusFilter || undefined }).then(res => {
+      var params = { page: 1, size: 100, status: this.statusFilter || undefined }
+      if (this.sortState.length > 0) params.sort = this.sortState
+      getUnknownClusters(params).then(res => {
         this.clusters = res.data && res.data.records ? res.data.records : (res.data || [])
       }).catch(() => {}).finally(() => { this.loading = false })
     },
@@ -265,4 +281,13 @@ export default {
 .t-ind-val { color: #666; margin-left: 12px; }
 .trainer-text { font-size: 13px; color: #555; line-height: 1.6; }
 .empty-hint { color: #bbb; text-align: center; padding: 20px; }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 26 - 2
cfc-web/src/views/admin/ReportManagement.vue

@@ -24,7 +24,11 @@
       </div>
 
       <el-table :data="tableData" border stripe v-loading="loading" style="margin-top:16px">
-        <el-table-column prop="id" label="ID" width="80"></el-table-column>
+        <el-table-column prop="id" label="ID" width="80">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="报告类型" width="100">
           <template slot-scope="scope">
             <el-tag size="small" :type="getTypeTag(scope.row.reportType)">{{ scope.row.reportTypeName }}</el-tag>
@@ -44,7 +48,11 @@
             <el-tag size="small">{{ scope.row.status }}</el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="createdAt" label="创建时间" width="160"></el-table-column>
+        <el-table-column prop="createdAt" label="创建时间" width="160">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间{{ sortIcon('createdAt') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="操作" width="120">
           <template slot-scope="scope">
             <el-button size="mini" type="text" @click="viewDetail(scope.row.id)">详情</el-button>
@@ -67,12 +75,15 @@
 
 <script>
 import axios from 'axios'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'ReportManagement',
+  mixins: [SortMixin],
   data() {
     return {
       tableData: [],
+      sortableColumns: { id: 'ID', createdAt: '创建时间' },
       total: 0,
       loading: false,
       query: {
@@ -88,6 +99,9 @@ export default {
     this.loadData()
   },
   methods: {
+    loadList() {
+      this.loadData()
+    },
     loadData() {
       var self = this
       self.loading = true
@@ -97,6 +111,7 @@ export default {
         type: self.query.type,
         keyword: self.query.keyword
       }
+      if (self.sortState.length > 0) params.sort = self.sortState
       if (self.dateRange && self.dateRange.length === 2) {
         params.startDate = self.dateRange[0].format ? self.dateRange[0].format('YYYY-MM-DD') : self.dateRange[0]
         params.endDate = self.dateRange[1].format ? self.dateRange[1].format('YYYY-MM-DD') : self.dateRange[1]
@@ -173,4 +188,13 @@ export default {
   gap: 12px;
   flex-wrap: wrap;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 28 - 3
cfc-web/src/views/admin/ReportParserImport.vue

@@ -9,7 +9,11 @@
       </div>
 
       <el-table :data="imports" v-loading="loading" border stripe>
-        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="id" label="ID" width="60">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="packageName" label="导入包名称" min-width="200" />
         <el-table-column prop="packageFileUrl" label="文件" width="150" />
         <el-table-column label="状态" width="100">
@@ -17,7 +21,11 @@
             <el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="createdAt" label="导入时间" width="180" />
+        <el-table-column prop="createdAt" label="导入时间" width="180">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">导入时间{{ sortIcon('createdAt') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="操作" width="160" fixed="right">
           <template slot-scope="{ row }">
             <template v-if="row.status === 'pending'">
@@ -77,13 +85,16 @@
 
 <script>
 import { getParserImports, approveParserImport, rejectParserImport, importParserPackage } from '@/api/reportParser'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'ReportParserImport',
+  mixins: [SortMixin],
   data() {
     return {
       loading: false, submitting: false,
       imports: [],
+      sortableColumns: { id: 'ID', createdAt: '导入时间' },
       uploadDialogVisible: false,
       previewDialogVisible: false,
       selectedFile: null,
@@ -92,10 +103,15 @@ export default {
   },
   mounted() { this.fetchImports() },
   methods: {
+    loadList() {
+      this.fetchImports()
+    },
     async fetchImports() {
       this.loading = true
       try {
-        const res = await getParserImports({ page: 1, size: 50 })
+        var params = { page: 1, size: 50 }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getParserImports(params)
         this.imports = res.data?.records || res.data || []
       } catch (e) { this.$message.error('加载失败') }
       this.loading = false
@@ -172,4 +188,13 @@ export default {
   white-space: pre-wrap;
   word-break: break-all;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 28 - 3
cfc-web/src/views/admin/ReportTemplateManage.vue

@@ -7,8 +7,16 @@
         <el-button size="small" type="primary" @click="openEditor(null)">+ 新增模板</el-button>
       </div>
       <el-table :data="templates" v-loading="loading" border stripe :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="60" />
-        <el-table-column prop="name" label="模板名称" min-width="160" />
+        <el-table-column prop="id" label="ID" width="60">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="name" label="模板名称" min-width="160">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('name', $event)">模板名称{{ sortIcon('name') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="reportType" label="报告类型" width="140">
           <template slot-scope="{ row }">{{ typeLabel(row.reportType) }}</template>
         </el-table-column>
@@ -191,16 +199,19 @@
 
 <script>
 import { getReportTemplates, saveReportTemplate, deleteReportTemplate, toggleReportTemplate, getAvailableIndicators } from '@/api/reportTemplate'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'ReportTemplateManage',
     components: {},
+  mixins: [SortMixin],
   data() {
     return {
       activeTab: 'list',
       loading: false,
       saving: false,
       templates: [],
+      sortableColumns: { id: 'ID', name: '模板名称' },
       indicatorList: [],
       isEdit: false,
       previewVisible: false,
@@ -216,6 +227,9 @@ export default {
   },
   mounted() { this.fetchTemplates() },
   methods: {
+    loadList() {
+      this.fetchTemplates()
+    },
     emptyForm() {
       return {
         id: null,
@@ -230,7 +244,9 @@ export default {
     },
     fetchTemplates() {
       this.loading = true
-      getReportTemplates({}).then(res => {
+      var params = {}
+      if (this.sortState.length > 0) params.sort = this.sortState
+      getReportTemplates(params).then(res => {
         this.templates = res.data || []
       }).finally(() => { this.loading = false })
     },
@@ -425,4 +441,13 @@ export default {
 .preview-table th { background: #f5f7fa; font-weight: 600; }
 .preview-text { font-size: 14px; color: #555; line-height: 1.7; white-space: pre-wrap; }
 .empty-hint { color: #bbb; text-align: center; padding: 40px; }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 28 - 3
cfc-web/src/views/admin/ReportTypeManagement.vue

@@ -10,9 +10,17 @@
       </div>
 
       <el-table :data="filteredTypes" v-loading="loading" border stripe>
-        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="id" label="ID" width="60">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="typeId" label="类型标识" width="140" />
-        <el-table-column prop="displayName" label="显示名称" min-width="160" />
+        <el-table-column prop="displayName" label="显示名称" min-width="160">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('displayName', $event)">显示名称{{ sortIcon('displayName') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="family" label="家族" width="100" />
         <el-table-column prop="parserPath" label="解析器路径" width="200" />
         <el-table-column prop="minConfidence" label="阈值" width="70" />
@@ -125,13 +133,16 @@
 <script>
 import { getReportTypes, saveReportType, toggleReportType, deleteReportType,
          getFingerprints, saveFingerprint, deleteFingerprint } from '@/api/reportParser'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'ReportTypeManagement',
+  mixins: [SortMixin],
   data() {
     return {
       loading: false, submitting: false, fpLoading: false, fpSubmitting: false,
       types: [], keyword: '',
+      sortableColumns: { id: 'ID', displayName: '显示名称' },
       dialogVisible: false, isEdit: false,
       form: { typeId: '', displayName: '', family: '', description: '', parserPath: '', minConfidence: 10 },
       fpDialogVisible: false, currentType: null,
@@ -151,10 +162,15 @@ export default {
   },
   mounted() { this.fetchTypes() },
   methods: {
+    loadList() {
+      this.fetchTypes()
+    },
     async fetchTypes() {
       this.loading = true
       try {
-        const res = await getReportTypes({ page: 1, size: 999 })
+        var params = { page: 1, size: 999 }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getReportTypes(params)
         this.types = (res.data?.records || res.data || [])
       } catch (e) { this.$message.error('加载失败') }
       this.loading = false
@@ -237,4 +253,13 @@ export default {
 
 <style scoped>
 .el-form-item-msg { font-size: 12px; color: #999; margin-top: 2px; }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 28 - 3
cfc-web/src/views/admin/ReportUnknownCluster.vue

@@ -14,9 +14,17 @@
       </div>
 
       <el-table :data="clusters" v-loading="loading" border stripe>
-        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="id" label="ID" width="60">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="clusterKey" label="聚类键" width="140" />
-        <el-table-column prop="reportCount" label="报告数" width="80" />
+        <el-table-column prop="reportCount" label="报告数" width="80">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('reportCount', $event)">报告数{{ sortIcon('reportCount') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="状态" width="100">
           <template slot-scope="{ row }">
             <el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
@@ -62,23 +70,31 @@
 
 <script>
 import { getUnknownClusters, getClusterUploads, generateTypeFromCluster } from '@/api/reportParser'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'ReportUnknownCluster',
+  mixins: [SortMixin],
   data() {
     return {
       loading: false, uploadLoading: false,
       clusters: [], statusFilter: '',
+      sortableColumns: { id: 'ID', reportCount: '报告数' },
       detailVisible: false, currentCluster: null,
       uploads: [],
     }
   },
   mounted() { this.fetchClusters() },
   methods: {
+    loadList() {
+      this.fetchClusters()
+    },
     async fetchClusters() {
       this.loading = true
       try {
-        const res = await getUnknownClusters({ page: 1, size: 50, status: this.statusFilter })
+        var params = { page: 1, size: 50, status: this.statusFilter }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getUnknownClusters(params)
         this.clusters = res.data?.records || res.data || []
       } catch (e) { this.$message.error('加载失败') }
       this.loading = false
@@ -130,4 +146,13 @@ export default {
   white-space: pre-wrap;
   word-break: break-all;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 29 - 3
cfc-web/src/views/admin/SeasonalFoods.vue

@@ -18,8 +18,16 @@
       </div>
       <div class="table-scroll-wrap">
       <el-table :data="list" v-loading="loading" border stripe :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="80" />
-        <el-table-column prop="foodId" label="食材ID" width="80" />
+        <el-table-column prop="id" label="ID" width="80">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="foodId" label="食材ID" width="80">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('foodId', $event)">食材ID{{ sortIcon('foodId') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="月份" width="80">
           <template slot-scope="{ row }">{{ row.month }}月</template>
         </el-table-column>
@@ -72,8 +80,11 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
+
 export default {
   name: 'SeasonalFoods',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -85,6 +96,7 @@ export default {
       submitting: false,
       loading: false,
       list: [],
+      sortableColumns: { id: 'ID', foodId: '食材ID' },
       dialogVisible: false,
       keyword: '',
       monthFilter: now.getMonth() + 1,
@@ -105,7 +117,9 @@ export default {
         const baseURL = process.env.VUE_APP_BASE_API || ''
         const token = localStorage.getItem('token')
         const axios = this.$axios || (await import('axios')).default
-        const res = await axios.post(baseURL + '/api/admin/seasonal/list', { month: this.monthFilter, keyword: this.keyword }, {
+        var body = { month: this.monthFilter, keyword: this.keyword }
+        if (this.sortState.length > 0) body.sort = this.sortState
+        const res = await axios.post(baseURL + '/api/admin/seasonal/list', body, {
           headers: { Authorization: 'Bearer ' + token }
         })
         this.list = res.data && res.data.data || []
@@ -167,3 +181,15 @@ export default {
   }
 }
 </script>
+
+<style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+</style>

+ 28 - 2
cfc-web/src/views/admin/SubscriptionManagement.vue

@@ -21,7 +21,11 @@
 
     <div class="table-scroll-wrap">
       <el-table :max-height="tableHeight" :data="list" v-loading="loading" border stripe>
-        <el-table-column prop="id" label="ID" width="70" />
+        <el-table-column prop="id" label="ID" width="70">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="家庭信息" min-width="180" show-overflow-tooltip>
           <template slot-scope="{ row }">
             <div style="display:flex;align-items:center;gap:8px;">
@@ -59,6 +63,9 @@
           <template slot-scope="{ row }">
             {{ formatTime(row.createdAt) }}
           </template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="200" fixed="right">
           <template slot-scope="{ row }">
@@ -170,10 +177,13 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getSubscriptionList, activateSubscription, cancelSubscriptionAdmin } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   name: 'SubscriptionManagement',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -199,7 +209,8 @@ export default {
       refundReason: '',
       activateDialogVisible: false,
       activateTarget: null,
-      activateForm: { expireTime: '' }
+      activateForm: { expireTime: '' },
+      sortableColumns: { id: 'ID', createdAt: '创建时间' }
     }
   },
   mounted() {
@@ -213,6 +224,7 @@ export default {
         if (this.statusFilter) params.status = this.statusFilter
         if (this.levelFilter) params.level = this.levelFilter
         if (this.keyword) params.keyword = this.keyword
+        if (this.sortState.length > 0) params.sort = this.sortState
         var res = await getSubscriptionList(params)
         this.list = res.data.records || res.data.list || res.data || []
         this.total = res.data.total || this.list.length
@@ -345,6 +357,20 @@ export default {
   gap: 10px;
   align-items: center;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 
 
 </style>

+ 34 - 3
cfc-web/src/views/admin/SupplierProductManage.vue

@@ -26,7 +26,11 @@
 
     <div class="table-scroll-wrap-sm">
         <el-table :max-height="tableHeight" :data="list" v-loading="loading" border stripe>
-          <el-table-column prop="id" label="ID" width="70" />
+          <el-table-column prop="id" label="ID" width="70">
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+            </template>
+          </el-table-column>
           <el-table-column label="商品图片" width="80">
             <template slot-scope="{ row }">
               <img
@@ -38,7 +42,11 @@
               <span v-else style="color:#999">-</span>
             </template>
           </el-table-column>
-          <el-table-column prop="name" label="商品名称" min-width="150" show-overflow-tooltip />
+          <el-table-column prop="name" label="商品名称" min-width="150" show-overflow-tooltip>
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('name', $event)">商品名称<span v-if="sortIndex('name') !== null" class="sort-index" :class="sortClass('name') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('name') }}</span>{{ sortIcon('name') }}</span>
+            </template>
+          </el-table-column>
           <el-table-column label="类型" width="90">
             <template slot-scope="{ row }">
               {{ typeLabel(row.productType) }}
@@ -66,6 +74,9 @@
             <template slot-scope="{ row }">
               {{ formatTime(row.createdAt) }}
             </template>
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+            </template>
           </el-table-column>
           <el-table-column label="操作" width="200" fixed="right">
             <template slot-scope="{ row }">
@@ -99,10 +110,13 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getSupplierProductList, shelveSupplierProduct } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   name: 'SupplierProductManage',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -120,7 +134,9 @@ export default {
         page: 1,
         size: 20,
         total: 0
-      }
+      },
+      sortableColumns: { id: 'ID', name: '商品名称', createdAt: '创建时间' },
+      sortableColumns: { name: '商品名称', createdAt: '创建时间', id: 'ID' }
     }
   },
   mounted() {
@@ -136,6 +152,7 @@ export default {
           status: this.filters.status || null,
           keyword: this.filters.keyword || null
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await getSupplierProductList(params)
         if (res.code === 200) {
           this.list = res.data.records || []
@@ -241,4 +258,18 @@ export default {
 .header-search {
   width: 180px;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 31 - 3
cfc-web/src/views/admin/SurveyTemplates.vue

@@ -8,7 +8,11 @@
       </div>
 
       <el-table :data="templates" v-loading="loading" border stripe>
-        <el-table-column prop="title" label="标题" min-width="150" />
+        <el-table-column prop="title" label="标题" min-width="150">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('title', $event)">标题<span v-if="sortIndex('title') !== null" class="sort-index" :class="sortClass('title') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('title') }}</span>{{ sortIcon('title') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="dimension" label="维度" width="80" />
         <el-table-column prop="cycleType" label="周期" width="100">
           <template slot-scope="scope">
@@ -34,6 +38,9 @@
           <template slot-scope="scope">
             {{ scope.row.createdAt && scope.row.createdAt.substring(0, 10) }}
           </template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="160" align="center">
           <template slot-scope="scope">
@@ -129,10 +136,13 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getSurveyTemplates, saveSurveyTemplate, deleteSurveyTemplate, aiGenerateSurveyQuestions } from '@/api/survey'
 
 export default {
+  mixins: [SortMixin],
   name: 'SurveyTemplates',
+  mixins: [SortMixin],
   data() {
     return {
       loading: false,
@@ -143,13 +153,15 @@ export default {
       aiResult: null,
       form: this.emptyForm(),
       aiForm: { dimension: '', topic: '' },
-      addOptionText: {}
+      addOptionText: {},
+      sortableColumns: { id: 'ID', createdAt: '创建时间' }
     }
   },
   created() {
     this.loadTemplates()
   },
   methods: {
+    loadList() { this.loadTemplates() },
     cycleLabel(type) {
       var map = { weekly: '每周', biweekly: '两周', monthly: '每月' }
       return map[type] || type || '-'
@@ -160,7 +172,9 @@ export default {
     async loadTemplates() {
       this.loading = true
       try {
-        var res = await getSurveyTemplates()
+        const params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        var res = await getSurveyTemplates(params)
         this.templates = res.data || []
       } catch (e) {
         this.$message.error('加载失败: ' + (e.message || '未知错误'))
@@ -292,4 +306,18 @@ export default {
   font-weight: bold;
   margin-right: auto;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 20 - 2
cfc-web/src/views/admin/SysConfig.vue

@@ -8,7 +8,11 @@
       </div>
     </div>
     <el-table :data="list" v-loading="loading" border stripe :max-height="tableHeight">
-      <el-table-column prop="configKey" label="配置键" min-width="200" show-overflow-tooltip />
+      <el-table-column prop="configKey" label="配置键" min-width="200" show-overflow-tooltip>
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('configKey', $event)">配置键<span v-if="sortIndex('configKey') !== null" class="sort-index" :class="sortClass('configKey') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('configKey') }}</span>{{ sortIcon('configKey') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="configValue" label="配置值" min-width="250" show-overflow-tooltip />
       <el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
       <el-table-column label="类型" width="90">
@@ -73,9 +77,11 @@
 
 <script>
 import { getSysConfigList, createSysConfig, updateSysConfig, deleteSysConfig } from '@/api/admin'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'SysConfig',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -88,6 +94,7 @@ export default {
       page: 1,
       size: 20,
       total: 0,
+      sortableColumns: { configKey: '配置键' },
       dialogVisible: false,
       dialogTitle: '新增配置',
       submitting: false,
@@ -112,7 +119,9 @@ export default {
     async loadList() {
       this.loading = true
       try {
-        const res = await getSysConfigList({ keyword: this.keyword || undefined })
+        const params = { keyword: this.keyword || undefined }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getSysConfigList(params)
         this.list = res.data || []
         this.total = (res.data || []).length
       } catch (e) {
@@ -192,4 +201,13 @@ export default {
 .sys-config {
   padding: 20px;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 27 - 2
cfc-web/src/views/admin/SystemPromptManagement.vue

@@ -31,6 +31,9 @@
           <template slot-scope="{ row }">
             {{ row.updatedAt ? formatTime(row.updatedAt) : '-' }}
           </template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('updatedAt', $event)">更新时间<span v-if="sortIndex('updatedAt') !== null" class="sort-index" :class="sortClass('updatedAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('updatedAt') }}</span>{{ sortIcon('updatedAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="180" fixed="right">
           <template slot-scope="{ row }">
@@ -93,10 +96,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getPromptList, savePrompt, resetPrompt, deletePrompt } from '@/api/systemPrompt'
 
 export default {
   name: 'SystemPromptManagement',
+  mixins: [SortMixin],
   data() {
     return {
       list: [],
@@ -108,13 +113,17 @@ export default {
       dialog: { visible: false, isEdit: false, form: {} },
       saving: false,
       previewVisible: false,
-      previewContent: ''
+      previewContent: '',
+      sortableColumns: { updatedAt: '更新时间' }
     }
   },
   created() {
     this.fetchList()
   },
   methods: {
+    loadList() {
+      this.fetchList()
+    },
     formatTime(ts) {
       if (!ts) return ''
       const d = new Date(ts)
@@ -123,7 +132,9 @@ export default {
     async fetchList() {
       this.loading = true
       try {
-        const res = await getPromptList({ keyword: this.filters.keyword, page: this.page, size: this.size })
+        var params = { keyword: this.filters.keyword, page: this.page, size: this.size }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getPromptList(params)
         if (res && res.data) {
           this.list = res.data.records || []
           this.total = res.data.total || 0
@@ -183,4 +194,18 @@ export default {
 .system-prompt-page {
   padding: 0;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 26 - 2
cfc-web/src/views/admin/UnifiedHealthReports.vue

@@ -24,7 +24,11 @@
           ref="listTable"
           @selection-change="handleSelectionChange" @row-click="goEdit">
           <el-table-column type="selection" width="48" :selectable="row => row && row.id ? true : false" />
-          <el-table-column prop="id" label="ID" width="70" />
+          <el-table-column prop="id" label="ID" width="70">
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('id', $event)">ID{{ sortIcon('id') }}</span>
+            </template>
+          </el-table-column>
           <el-table-column label="报告类型" width="100">
             <template slot-scope="{ row }">
               <el-tag size="small" :type="getTypeTag(row.reportType || row.reportTypeName)">{{ row.reportTypeName || row.reportType }}</el-tag>
@@ -45,7 +49,11 @@
           <el-table-column label="营养" width="70" v-if="false">
             <template slot-scope="{ row }">{{ row.nutritionScore || '-' }}</template>
           </el-table-column>
-          <el-table-column prop="reportDate" label="报告日期" width="120" />
+          <el-table-column prop="reportDate" label="报告日期" width="120">
+            <template slot="header">
+              <span class="sort-header" @click.stop="onSortClick('reportDate', $event)">报告日期{{ sortIcon('reportDate') }}</span>
+            </template>
+          </el-table-column>
           <el-table-column label="状态" width="90">
             <template slot-scope="{ row }">
               <el-tag size="small">{{ row.status || '已确认' }}</el-tag>
@@ -180,12 +188,15 @@
 import axios from 'axios'
 import { getFamilyList, getFamilyMemberList } from '@/api/admin'
 import { proxyParseReport, proxyConfirmReport, listHealthReports, getHealthReportDetail, archiveReportsBatch, archiveReport } from '@/api/admin'
+import SortMixin from '@/mixins/SortMixin'
 
 export default {
   name: 'UnifiedHealthReports',
+  mixins: [SortMixin],
   data() {
     return {
       activeTab: 'list',
+      sortableColumns: { id: 'ID', reportDate: '报告日期' },
 
       // 报告列表
       reportList: [],
@@ -232,10 +243,14 @@ export default {
   },
   methods: {
     // ===== 报告列表 =====
+    loadList() {
+      this.loadReportList()
+    },
     async loadReportList() {
       this.listLoading = true
       try {
         var params = { page: this.listPage, size: this.listSize, type: this.query.type, keyword: this.query.keyword }
+        if (this.sortState.length > 0) params.sort = this.sortState
         if (this.dateRange && this.dateRange.length === 2) {
           params.startDate = this.dateRange[0].format ? this.dateRange[0].format('YYYY-MM-DD') : this.dateRange[0]
           params.endDate = this.dateRange[1].format ? this.dateRange[1].format('YYYY-MM-DD') : this.dateRange[1]
@@ -387,4 +402,13 @@ export default {
 .score-card { border: 2px solid #ebeef5; border-radius: 8px; padding: 12px; text-align: center; background: #fafafa; }
 .score-label { font-size: 13px; color: #909399; margin-bottom: 6px; }
 .score-value { font-size: 24px; font-weight: bold; }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 32 - 4
cfc-web/src/views/admin/VirtualTeamList.vue

@@ -21,8 +21,16 @@
     </div>
 
     <el-table :max-height="tableHeight" :data="pagedData" stripe v-loading="loading">
-        <el-table-column prop="id" label="ID" width="80"></el-table-column>
-        <el-table-column prop="name" label="团队名称" width="160"></el-table-column>
+        <el-table-column prop="id" label="ID" width="80">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="name" label="团队名称" width="160">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('name', $event)">团队名称<span v-if="sortIndex('name') !== null" class="sort-index" :class="sortClass('name') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('name') }}</span>{{ sortIcon('name') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="description" label="描述" show-overflow-tooltip></el-table-column>
         <el-table-column prop="ownerId" label="负责人ID" width="100"></el-table-column>
         <el-table-column prop="profitRate" label="利润率" width="100">
@@ -41,6 +49,9 @@
           <template slot-scope="scope">
             {{ formatDate(scope.row.createdAt) }}
           </template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="200" fixed="right">
           <template slot-scope="{ row }">
@@ -102,6 +113,7 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import {
   listVirtualTeams,
   createVirtualTeam,
@@ -110,7 +122,9 @@ import {
 } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   name: 'VirtualTeamList',
+  mixins: [SortMixin],
   data() {
     return {
       teams: [],
@@ -125,7 +139,9 @@ export default {
       form: this.getEmptyForm(),
       formRules: {
         name: [{ required: true, message: '请输入团队名称', trigger: 'blur' }]
-      }
+      },
+      sortableColumns: { id: 'ID', createdAt: '创建时间', name: '团队名称' },
+      sortableColumns: { name: '团队名称', createdAt: '创建时间', id: 'ID' }
     }
   },
   computed: {
@@ -148,6 +164,7 @@ export default {
     this.loadData()
   },
   methods: {
+    loadList() { this.loadData() },
     getEmptyForm() {
       return { id: null, name: '', description: '', profitRate: 0 }
     },
@@ -161,7 +178,9 @@ export default {
     async loadData() {
       this.loading = true
       try {
-        var res = await listVirtualTeams({})
+        var params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        var res = await listVirtualTeams(params)
         this.teams = res.data || []
       } catch (e) {
         console.error(e)
@@ -246,4 +265,13 @@ export default {
 <style scoped>
 .virtual-team-list { padding: 20px; }
 .toolbar { margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 27 - 3
cfc-web/src/views/admin/ZodiacConfigs.vue

@@ -10,7 +10,11 @@
       </div>
       <div class="table-scroll-wrap">
       <el-table :data="filteredData" stripe :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="80"></el-table-column>
+        <el-table-column prop="id" label="ID" width="80">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="zodiacCode" label="代码" width="120"></el-table-column>
         <el-table-column prop="zodiacName" label="名称"></el-table-column>
         <el-table-column prop="element" label="五行"></el-table-column>
@@ -98,9 +102,11 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { listZodiacConfigs, createZodiacConfig, updateZodiacConfig, deleteZodiacConfig } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   name: 'ZodiacConfigs',
   data() {
     return {
@@ -110,7 +116,8 @@ export default {
       dialogVisible: false,
       successDialogVisible: false,
       isEdit: false,
-      form: this.getEmptyForm()
+      form: this.getEmptyForm(),
+      sortableColumns: { id: 'ID' }
     }
   },
   created() {
@@ -148,12 +155,15 @@ export default {
     },
     async loadData() {
       try {
-        const res = await listZodiacConfigs()
+        const params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await listZodiacConfigs(params)
         if (res.data) this.configs = res.data
       } catch (e) {
         console.error(e)
       }
     },
+    loadList() { this.loadData() },
     handleSearch() {
     },
     handleCreate() {
@@ -212,4 +222,18 @@ export default {
 </script>
 
 <style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 32 - 3
cfc-web/src/views/admin/data-source-config.vue

@@ -29,6 +29,9 @@
         </el-table-column>
         <el-table-column prop="createdAt" label="创建时间" width="160">
           <template slot-scope="scope">{{ formatDate(scope.row.createdAt) }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="200" fixed="right">
           <template slot-scope="{ row }">
@@ -102,8 +105,11 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
+
 export default {
   name: 'DataSourceConfig',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -121,13 +127,17 @@ export default {
       successDialogVisible: false,
       isEdit: false,
       form: this.emptyForm(),
-      keyword: ''
+      keyword: '',
+      sortableColumns: { createdAt: '创建时间' }
     }
   },
   mounted() {
     this.fetchRecords()
   },
   methods: {
+    loadList() {
+      this.fetchRecords()
+    },
     handleSearch() {
       this.page = 1
       this.fetchRecords()
@@ -149,7 +159,9 @@ export default {
     },
     fetchRecords() {
       this.loading = true
-      this.$axios.post('/api/admin/dimension/data-source/list', null, { params: { page: this.page, size: this.size, keyword: this.keyword || undefined } })
+      var params = { page: this.page, size: this.size, keyword: this.keyword || undefined }
+      if (this.sortState.length > 0) params.sort = this.sortState
+      this.$axios.post('/api/admin/dimension/data-source/list', null, { params: params })
         .then(res => {
           if (res.data.code === 200) {
             this.records = res.data.data.records || []
@@ -210,4 +222,21 @@ export default {
     }
   }
 }
-</script>
+</script>
+
+<style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
+</style>

+ 26 - 4
cfc-web/src/views/admin/gates.vue

@@ -8,6 +8,9 @@
 
       <el-table :data="gateList" border v-loading="loading" style="width:100%">
         <el-table-column prop="sortOrder" label="排序" width="60" align="center">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('sortOrder', $event)">排序<span v-if="sortIndex('sortOrder') !== null" class="sort-index" :class="sortClass('sortOrder') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('sortOrder') }}</span>{{ sortIcon('sortOrder') }}</span>
+          </template>
           <template slot-scope="scope">
             <el-button size="mini" @click="moveUp(scope.$index)" :disabled="scope.$index === 0">▲</el-button>
             <el-button size="mini" @click="moveDown(scope.$index)" :disabled="scope.$index === gateList.length-1">▼</el-button>
@@ -16,7 +19,11 @@
         <el-table-column prop="icon" label="图标" width="60" align="center">
           <template slot-scope="scope">{{ scope.row.icon || '—' }}</template>
         </el-table-column>
-        <el-table-column prop="name" label="名称" width="140"></el-table-column>
+        <el-table-column prop="name" label="名称" width="140">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('name', $event)">名称<span v-if="sortIndex('name') !== null" class="sort-index" :class="sortClass('name') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('name') }}</span>{{ sortIcon('name') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="gateType" label="类型" width="140">
           <template slot-scope="scope">{{ gateTypeLabel(scope.row.gateType) }}</template>
         </el-table-column>
@@ -177,6 +184,7 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getUnlockGateList, saveUnlockGate, deleteUnlockGate, sortUnlockGates, toggleUnlockGate, resetFamilyGates, getFamilyGateProgress } from '@/api/admin'
 
 const GATE_TYPE_LABELS = {
@@ -202,6 +210,7 @@ const UNLOCK_TARGET_LABELS = {
 }
 
 export default {
+  mixins: [SortMixin],
   data() {
     return {
       loading: false,
@@ -222,13 +231,15 @@ export default {
       familyProgressSearch: {
         familyId: ''
       },
-      familyProgressList: []
+      familyProgressList: [],
+      sortableColumns: { name: '关卡名称', createdAt: '创建时间', id: 'ID' }
     }
   },
   mounted() {
     this.loadGates()
   },
   methods: {
+    loadList() { this.loadGates() },
     gateTypeLabel(type) {
       return GATE_TYPE_LABELS[type] || type
     },
@@ -256,10 +267,12 @@ export default {
     async loadGates() {
       this.loading = true
       try {
-        const res = await getUnlockGateList({
+        const params = {
           page: this.page.current,
           size: this.page.size
-        })
+        }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getUnlockGateList(params)
         var data = res.data || {}
         var records = data.records || []
         // 删除当前页最后一条后回退页码,避免空页
@@ -438,4 +451,13 @@ export default {
 .gates-page {
   padding: 20px;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 27 - 2
cfc-web/src/views/admin/health-knowledge.vue

@@ -22,8 +22,16 @@
       </el-form>
 
       <el-table :data="list" border stripe v-loading="loading" empty-text="暂无数据" style="width:100%" :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="60" />
-        <el-table-column prop="itemName" label="指标名称" min-width="140" />
+        <el-table-column prop="id" label="ID" width="60">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="itemName" label="指标名称" min-width="140">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('itemName', $event)">指标名称{{ sortIcon('itemName') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="category" label="分类" width="140" />
         <el-table-column prop="normalRange" label="正常范围" width="140" />
         <el-table-column prop="description" label="说明" min-width="260" show-overflow-tooltip />
@@ -66,7 +74,10 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
+
 export default {
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -76,6 +87,7 @@ export default {
     return {
       loading: false,
       list: [],
+      sortableColumns: { id: 'ID', itemName: '指标名称' },
       filters: { name: '', category: '' },
       categories: ['维生素', '氨基酸', '微量元素', '短链脂肪酸', '神经递质与激素', '肠道屏障与代谢物', '抗生素风险评估'],
       dialog: { visible: false, isEdit: false, saving: false, form: {} }
@@ -83,11 +95,15 @@ export default {
   },
   mounted() { this.fetchList() },
   methods: {
+    loadList() {
+      this.fetchList()
+    },
     fetchList() {
       this.loading = true
       var params = {}
       if (this.filters.name) params.name = this.filters.name
       if (this.filters.category) params.category = this.filters.category
+      if (this.sortState.length > 0) params.sort = this.sortState
       this.$axios.post('/api/health/knowledge/list', params).then(res => {
         var data = res.data || {}
         if (data.code === 200) {
@@ -150,4 +166,13 @@ export default {
 
 <style scoped>
 .knowledge-page { padding: 20px; }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 31 - 4
cfc-web/src/views/admin/indicators/index.vue

@@ -17,7 +17,11 @@
 
     <!-- 表格 -->
       <el-table :data="list" border stripe v-loading="loading" :max-height="tableHeight">
-      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="id" label="ID" width="60">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="domain" label="领域" width="100" />
       <el-table-column prop="category" label="分类" width="100" />
       <el-table-column prop="code" label="编码" width="160" />
@@ -92,8 +96,11 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
+
 export default {
   name: 'IndicatorsIndex',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -119,19 +126,25 @@ export default {
         unit: '',
         sortOrder: 0,
         status: 1
-      }
+      },
+      sortableColumns: { id: 'ID' }
     }
   },
   mounted() {
     this.loadData()
   },
   methods: {
+    loadList() {
+      this.loadData()
+    },
     loadData() {
       this.loading = true
-      this.$http.post('/api/admin/indicator/list', {
+      var params = {
         domain: this.filterDomain || undefined,
         status: 1
-      }).then(res => {
+      }
+      if (this.sortState.length > 0) params.sort = this.sortState
+      this.$http.post('/api/admin/indicator/list', params).then(res => {
         if (res.code === 0) {
           this.list = res.data || []
           if (this.filterKeyword) {
@@ -199,4 +212,18 @@ export default {
 .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
 .page-header h2 { margin: 0; font-size: 20px; color: #1a1a2e; }
 .filter-bar { margin-bottom: 16px; display: flex; align-items: center; }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 15 - 1
cfc-web/src/views/admin/review/ActivityAudit.vue

@@ -11,7 +11,11 @@
       <el-button size="small" @click="loadData" :loading="loading">刷新</el-button>
     </div>
     <el-table :data="list" v-loading="loading" border stripe style="min-width:900px;" :max-height="tableHeight">
-      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="id" label="ID" width="60">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="title" label="活动名称" min-width="200" show-overflow-tooltip />
       <el-table-column label="审核状态" width="100">
         <template slot-scope="{ row }">
@@ -41,10 +45,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getActivityList, auditActivity } from '@/api/activity'
 
 export default {
   name: 'ActivityAudit',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -53,11 +59,13 @@ export default {
   data() {
     return {
       list: [], loading: false, keyword: '', statusFilter: 'pending',
+      sortableColumns: { id: 'ID' },
       rejectDialogVisible: false, rejectTarget: null, rejectReason: ''
     }
   },
   mounted() { this.loadData() },
   methods: {
+    loadList() { this.loadData() },
     statusType(s) { return { pending: 'warning', approved: 'success', rejected: 'danger' }[s] || 'info' },
     statusLabel(s) { return { pending: '待审核', approved: '已通过', rejected: '已拒绝' }[s] || s },
     handleSearch() { this.loadData() },
@@ -65,6 +73,7 @@ export default {
       this.loading = true
       try {
         const params = { auditStatus: this.statusFilter || undefined, keyword: this.keyword || undefined, page: 1, size: 50 }
+        if (this.sortState.length > 0) params.sort = this.sortState
         const res = await getActivityList(params)
         this.list = res.data && res.data.records || res.data && res.data.list || res.data || []
       } catch (e) { this.$message.error('加载失败')
@@ -90,4 +99,9 @@ export default {
 <style scoped>
 .filter-bar { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; padding: 12px; background: #fff; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.08); }
 .no-op { color: #ccc; }
+.sort-header { cursor: pointer; user-select: none; font-weight: 500; font-size: 12px; }
+.sort-header:hover { color: #409eff; }
+.sort-index { color: #fff; border-radius: 8px; font-size: 10px; padding: 0 4px; margin: 0 2px; }
+.sort-asc-badge { background: #f56c6c; }
+.sort-desc-badge { background: #409eff; }
 </style>

+ 18 - 3
cfc-web/src/views/admin/review/ActivityRegistrationAudit.vue

@@ -10,7 +10,11 @@
       <el-button size="small" @click="loadData" :loading="loading">刷新</el-button>
     </div>
     <el-table :data="list" v-loading="loading" border stripe style="min-width:900px;" :max-height="tableHeight">
-      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="id" label="ID" width="60">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column label="用户" width="120">
         <template slot-scope="{ row }">{{ row.nickname || row.userName || '-' }}</template>
       </el-table-column>
@@ -36,11 +40,13 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getActivityList } from '@/api/activity'
 import { approveRegistration, rejectRegistration } from '@/api/activity'
 
 export default {
   name: 'ActivityRegistrationAudit',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -48,17 +54,21 @@ export default {
   },
   data() {
     return {
-      list: [], loading: false, statusFilter: 'pending'
+      list: [], loading: false, statusFilter: 'pending',
+      sortableColumns: { id: 'ID' }
     }
   },
   mounted() { this.loadData() },
   methods: {
+    loadList() { this.loadData() },
     statusType(s) { return { pending: 'warning', approved: 'success', rejected: 'danger' }[s] || 'info' },
     statusLabel(s) { return { pending: '待审核', approved: '已通过', rejected: '已拒绝' }[s] || s },
     async loadData() {
       this.loading = true
       try {
-        const res = await getActivityList({ withRegistrations: true, page: 1, size: 50 })
+        const params = { withRegistrations: true, page: 1, size: 50 }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getActivityList(params)
         var regs = []
         var activities = res.data && res.data.records || res.data && res.data.list || res.data || []
         for (var i = 0; i < activities.length; i++) {
@@ -96,4 +106,9 @@ export default {
 <style scoped>
 .filter-bar { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; padding: 12px; background: #fff; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.08); }
 .no-op { color: #ccc; }
+.sort-header { cursor: pointer; user-select: none; font-weight: 500; font-size: 12px; }
+.sort-header:hover { color: #409eff; }
+.sort-index { color: #fff; border-radius: 8px; font-size: 10px; padding: 0 4px; margin: 0 2px; }
+.sort-asc-badge { background: #f56c6c; }
+.sort-desc-badge { background: #409eff; }
 </style>

+ 23 - 4
cfc-web/src/views/admin/review/ArticleAudit.vue

@@ -14,7 +14,11 @@
     </div>
     <el-table :data="list" v-loading="loading" border stripe style="min-width:900px;" :max-height="tableHeight" @selection-change="onSelectionChange">
       <el-table-column type="selection" width="40" v-if="statusFilter === 'pending'" />
-      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="id" label="ID" width="60">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
       <el-table-column label="审核状态" width="90">
         <template slot-scope="{ row }">
@@ -27,7 +31,11 @@
           <span v-else style="color:#999;">-</span>
         </template>
       </el-table-column>
-      <el-table-column prop="createdAt" label="申请时间" width="160" />
+      <el-table-column prop="createdAt" label="申请时间" width="160">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">申请时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column label="操作" width="200" fixed="right">
         <template slot-scope="{ row }">
           <template v-if="row.auditStatus === 'pending'">
@@ -98,10 +106,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { adminArticleList, adminArticleAudit } from '@/api/article'
 
 export default {
   name: 'ArticleAudit',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -116,6 +126,7 @@ export default {
       page: 1,
       size: 20,
       total: 0,
+      sortableColumns: { id: 'ID', createdAt: '申请时间' },
       selectedRows: [],
       detailVisible: false,
       rejectDialogVisible: false,
@@ -129,16 +140,19 @@ export default {
     this.loadData()
   },
   methods: {
+    loadList() { this.loadData() },
     async loadData() {
       this.loading = true
       try {
-        const res = await adminArticleList({
+        const params = {
           page: this.page,
           size: this.size,
           status: 'draft',
           auditStatus: this.statusFilter || undefined,
           keyword: this.keyword || undefined
-        })
+        }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await adminArticleList(params)
         if (res.code === 200) {
           this.list = res.data && res.data.records || res.data && res.data.list || []
           this.total = (res.data && res.data.total) || 0
@@ -247,4 +261,9 @@ export default {
 
 <style scoped>
 .filter-bar { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; padding: 12px; background: #fff; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.08); flex-wrap: wrap; }
+.sort-header { cursor: pointer; user-select: none; font-weight: 500; font-size: 12px; }
+.sort-header:hover { color: #409eff; }
+.sort-index { color: #fff; border-radius: 8px; font-size: 10px; padding: 0 4px; margin: 0 2px; }
+.sort-asc-badge { background: #f56c6c; }
+.sort-desc-badge { background: #409eff; }
 </style>

+ 17 - 2
cfc-web/src/views/admin/review/ButlerAudit.vue

@@ -10,7 +10,11 @@
       <el-button size="small" @click="loadData" :loading="loading">刷新</el-button>
     </div>
     <el-table :data="list" v-loading="loading" border stripe style="min-width:900px;" :max-height="tableHeight">
-      <el-table-column prop="id" label="档案ID" width="80" />
+      <el-table-column prop="id" label="档案ID" width="80">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)">档案ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column label="用户" width="120">
           <template slot-scope="{ row }">{{ row.userName || '用户' + row.userId }}</template>
         </el-table-column>
@@ -52,11 +56,13 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getButlerList, reviewButler, toggleButlerAccepting } from '@/api/butler'
 import { searchUsers } from '@/api/admin'
 
 export default {
   name: 'ButlerAudit',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -65,11 +71,13 @@ export default {
   data() {
     return {
       list: [], loading: false, statusFilter: 'pending',
+      sortableColumns: { id: '档案ID' },
       rejectDialogVisible: false, rejectTarget: null, rejectReason: ''
     }
   },
   mounted() { this.loadData() },
   methods: {
+    loadList() { this.loadData() },
     statusType(s) { return { pending: 'warning', approved: 'success', rejected: 'danger' }[s] || 'info' },
     statusLabel(s) { return { pending: '待审核', approved: '已通过', rejected: '已拒绝' }[s] || s },
     async onToggleAccepting(row) {
@@ -89,7 +97,9 @@ export default {
     async loadData() {
       this.loading = true
       try {
-        const res = await getButlerList({ status: this.statusFilter || undefined, page: 1, size: 50 })
+        const params = { status: this.statusFilter || undefined, page: 1, size: 50 }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getButlerList(params)
         this.list = res.data && res.data.records || res.data && res.data.list || res.data || []
         await this.resolveUserNames(this.list, 'userId')
       } catch (e) { this.$message.error('加载失败')
@@ -146,4 +156,9 @@ export default {
 <style scoped>
 .filter-bar { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; padding: 12px; background: #fff; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.08); }
 .no-op { color: #ccc; }
+.sort-header { cursor: pointer; user-select: none; font-weight: 500; font-size: 12px; }
+.sort-header:hover { color: #409eff; }
+.sort-index { color: #fff; border-radius: 8px; font-size: 10px; padding: 0 4px; margin: 0 2px; }
+.sort-asc-badge { background: #f56c6c; }
+.sort-desc-badge { background: #409eff; }
 </style>

+ 18 - 3
cfc-web/src/views/admin/review/GuideAudit.vue

@@ -11,7 +11,11 @@
       <el-button size="small" @click="loadData" :loading="loading">刷新</el-button>
     </div>
     <el-table :data="list" v-loading="loading" border stripe style="min-width:900px;" :max-height="tableHeight">
-      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="id" label="ID" width="60">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="nickname" label="昵称" width="120" />
       <el-table-column prop="realName" label="真实姓名" width="120" />
       <el-table-column prop="phone" label="手机号" width="140" />
@@ -52,10 +56,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getGuideApplications, approveGuide, rejectGuide } from '@/api/admin'
 
 export default {
   name: 'GuideAudit',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -65,11 +71,13 @@ export default {
     return {
       list: [], loading: false, keyword: '', statusFilter: 'pending',
       page: 1, size: 20, total: 0,
+      sortableColumns: { id: 'ID' },
       rejectDialogVisible: false, rejectTarget: null, rejectReason: ''
     }
   },
   mounted() { this.loadData() },
   methods: {
+    loadList() { this.loadData() },
     statusType(s) { return { pending: 'warning', approved: 'success', rejected: 'danger' }[s] || 'info' },
     statusLabel(s) { return { pending: '待审核', approved: '已通过', rejected: '已拒绝' }[s] || s },
     handleSearch() { this.page = 1; this.loadData() },
@@ -77,12 +85,14 @@ export default {
     async loadData() {
       this.loading = true
       try {
-        const res = await getGuideApplications({
+        const params = {
           status: this.statusFilter || undefined,
           keyword: this.keyword || undefined,
           page: this.page,
           size: this.size
-        })
+        }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getGuideApplications(params)
         if (res.data && res.data.records) {
           this.list = res.data.records
           this.total = res.data.total || 0
@@ -124,4 +134,9 @@ export default {
 <style scoped>
 .filter-bar { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; padding: 12px; background: #fff; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.08); }
 .no-op { color: #ccc; }
+.sort-header { cursor: pointer; user-select: none; font-weight: 500; font-size: 12px; }
+.sort-header:hover { color: #409eff; }
+.sort-index { color: #fff; border-radius: 8px; font-size: 10px; padding: 0 4px; margin: 0 2px; }
+.sort-asc-badge { background: #f56c6c; }
+.sort-desc-badge { background: #409eff; }
 </style>

+ 17 - 2
cfc-web/src/views/admin/review/HierarchyAudit.vue

@@ -10,7 +10,11 @@
       <el-button size="small" @click="loadData" :loading="loading">刷新</el-button>
     </div>
     <el-table :data="list" v-loading="loading" border stripe style="min-width:900px;" :max-height="tableHeight">
-      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="id" label="ID" width="60">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="applicantName" label="申请人" width="120" />
       <el-table-column prop="currentSuperior" label="当前上级" width="120" />
       <el-table-column prop="targetSuperior" label="目标上级" width="120" />
@@ -42,11 +46,13 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import request from '@/utils/request'
 import { reviewSupplyHierarchyChange } from '@/api/admin'
 
 export default {
   name: 'HierarchyAudit',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -55,17 +61,21 @@ export default {
   data() {
     return {
       list: [], loading: false, statusFilter: 'pending',
+      sortableColumns: { id: 'ID' },
       rejectDialogVisible: false, rejectTarget: null, rejectReason: ''
     }
   },
   mounted() { this.loadData() },
   methods: {
+    loadList() { this.loadData() },
     statusType(s) { return { pending: 'warning', approved: 'success', rejected: 'danger' }[s] || 'info' },
     statusLabel(s) { return { pending: '待审核', approved: '已通过', rejected: '已拒绝' }[s] || s },
     async loadData() {
       this.loading = true
       try {
-        const res = await request({ url: '/api/admin/supply-hierarchy/change-requests', method: 'post', data: { status: this.statusFilter || undefined } })
+        const data = { status: this.statusFilter || undefined }
+        if (this.sortState.length > 0) data.sort = this.sortState
+        const res = await request({ url: '/api/admin/supply-hierarchy/change-requests', method: 'post', data: data })
         this.list = res.data && res.data.records || res.data || []
       } catch (e) { this.$message.error('加载失败')
       } finally { this.loading = false }
@@ -90,4 +100,9 @@ export default {
 <style scoped>
 .filter-bar { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; padding: 12px; background: #fff; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.08); }
 .no-op { color: #ccc; }
+.sort-header { cursor: pointer; user-select: none; font-weight: 500; font-size: 12px; }
+.sort-header:hover { color: #409eff; }
+.sort-index { color: #fff; border-radius: 8px; font-size: 10px; padding: 0 4px; margin: 0 2px; }
+.sort-asc-badge { background: #f56c6c; }
+.sort-desc-badge { background: #409eff; }
 </style>

+ 18 - 3
cfc-web/src/views/admin/review/NutritionistAudit.vue

@@ -11,7 +11,11 @@
       <el-button size="small" @click="loadData" :loading="loading">刷新</el-button>
     </div>
     <el-table :data="list" v-loading="loading" border stripe style="min-width:900px;" :max-height="tableHeight">
-      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="id" label="ID" width="60">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="nickname" label="昵称" width="120" />
       <el-table-column prop="realName" label="真实姓名" width="120" />
       <el-table-column prop="phone" label="手机号" width="140" />
@@ -42,10 +46,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getNutritionistApplications, approveNutritionist, rejectNutritionist } from '@/api/admin'
 
 export default {
   name: 'NutritionistAudit',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -54,21 +60,25 @@ export default {
   data() {
     return {
       list: [], loading: false, keyword: '', statusFilter: 'pending',
+      sortableColumns: { id: 'ID' },
       rejectDialogVisible: false, rejectTarget: null, rejectReason: ''
     }
   },
   mounted() { this.loadData() },
   methods: {
+    loadList() { this.loadData() },
     statusType(s) { return { pending: 'warning', approved: 'success', rejected: 'danger' }[s] || 'info' },
     statusLabel(s) { return { pending: '待审核', approved: '已通过', rejected: '已拒绝' }[s] || s },
     handleSearch() { this.loadData() },
     async loadData() {
       this.loading = true
       try {
-        const res = await getNutritionistApplications({
+        const params = {
           status: this.statusFilter || undefined,
           keyword: this.keyword || undefined, page: 1, size: 50
-        })
+        }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getNutritionistApplications(params)
         if (res.data && res.data.records) {
           this.list = res.data.records
         } else if (res.data && Array.isArray(res.data)) {
@@ -107,4 +117,9 @@ export default {
 <style scoped>
 .filter-bar { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; padding: 12px; background: #fff; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.08); }
 .no-op { color: #ccc; }
+.sort-header { cursor: pointer; user-select: none; font-weight: 500; font-size: 12px; }
+.sort-header:hover { color: #409eff; }
+.sort-index { color: #fff; border-radius: 8px; font-size: 10px; padding: 0 4px; margin: 0 2px; }
+.sort-asc-badge { background: #f56c6c; }
+.sort-desc-badge { background: #409eff; }
 </style>

+ 22 - 3
cfc-web/src/views/admin/review/PackageAudit.vue

@@ -13,7 +13,11 @@
     </div>
     <el-table :data="list" v-loading="loading" border stripe style="min-width:900px;" :max-height="tableHeight" @selection-change="onSelectionChange">
       <el-table-column type="selection" width="40" v-if="statusFilter === 'pending'" />
-      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="id" label="ID" width="60">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="name" label="任务模板名称" min-width="180" show-overflow-tooltip />
       <el-table-column prop="cycleDays" label="周期" width="70">
         <template slot-scope="{ row }">{{ row.cycleDays }}天</template>
@@ -32,7 +36,11 @@
           <span v-else style="color:#999;">-</span>
         </template>
       </el-table-column>
-      <el-table-column prop="createdAt" label="申请时间" width="160" />
+      <el-table-column prop="createdAt" label="申请时间" width="160">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">申请时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column label="操作" width="200" fixed="right">
         <template slot-scope="{ row }">
           <template v-if="row.status === 'pending'">
@@ -101,10 +109,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getPackageList, reviewPackage } from '@/api/package'
 
 export default {
   name: 'PackageAudit',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -118,6 +128,7 @@ export default {
       page: 1,
       size: 20,
       total: 0,
+      sortableColumns: { id: 'ID', createdAt: '申请时间' },
       selectedRows: [],
       detailVisible: false,
       rejectDialogVisible: false,
@@ -131,10 +142,13 @@ export default {
     this.loadData()
   },
   methods: {
+    loadList() { this.loadData() },
     async loadData() {
       this.loading = true
       try {
-        const res = await getPackageList({})
+        const params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getPackageList(params)
         if (res.code === 200) {
           let data = Array.isArray(res.data) ? res.data : (res.data && res.data.records) || []
           if (this.statusFilter) {
@@ -244,4 +258,9 @@ export default {
 
 <style scoped>
 .filter-bar { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; padding: 12px; background: #fff; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.08); flex-wrap: wrap; }
+.sort-header { cursor: pointer; user-select: none; font-weight: 500; font-size: 12px; }
+.sort-header:hover { color: #409eff; }
+.sort-index { color: #fff; border-radius: 8px; font-size: 10px; padding: 0 4px; margin: 0 2px; }
+.sort-asc-badge { background: #f56c6c; }
+.sort-desc-badge { background: #409eff; }
 </style>

+ 17 - 2
cfc-web/src/views/admin/review/ProductAudit.vue

@@ -10,7 +10,11 @@
       <el-button size="small" @click="loadData" :loading="loading">刷新</el-button>
     </div>
     <el-table :data="list" v-loading="loading" border stripe style="min-width:900px;" :max-height="tableHeight">
-      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="id" label="ID" width="60">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="name" label="商品名称" min-width="180" show-overflow-tooltip />
       <el-table-column label="审核状态" width="100">
         <template slot-scope="{ row }">
@@ -40,10 +44,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getProductList, reviewProduct } from '@/api/admin'
 
 export default {
   name: 'ProductAudit',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -52,17 +58,21 @@ export default {
   data() {
     return {
       list: [], loading: false, statusFilter: 'pending',
+      sortableColumns: { id: 'ID' },
       rejectDialogVisible: false, rejectTarget: null, rejectReason: ''
     }
   },
   mounted() { this.loadData() },
   methods: {
+    loadList() { this.loadData() },
     statusType(s) { return { pending: 'warning', approved: 'success', rejected: 'danger' }[s] || 'info' },
     statusLabel(s) { return { pending: '待审核', approved: '已通过', rejected: '已拒绝' }[s] || s },
     async loadData() {
       this.loading = true
       try {
-        const res = await getProductList({ status: this.statusFilter || undefined, page: 1, size: 50 })
+        const params = { status: this.statusFilter || undefined, page: 1, size: 50 }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getProductList(params)
         this.list = res.data && res.data.records || res.data && res.data.list || res.data || []
       } catch (e) { this.$message.error('加载失败')
       } finally { this.loading = false }
@@ -87,4 +97,9 @@ export default {
 <style scoped>
 .filter-bar { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; padding: 12px; background: #fff; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.08); }
 .no-op { color: #ccc; }
+.sort-header { cursor: pointer; user-select: none; font-weight: 500; font-size: 12px; }
+.sort-header:hover { color: #409eff; }
+.sort-index { color: #fff; border-radius: 8px; font-size: 10px; padding: 0 4px; margin: 0 2px; }
+.sort-asc-badge { background: #f56c6c; }
+.sort-desc-badge { background: #409eff; }
 </style>

+ 17 - 2
cfc-web/src/views/admin/review/VendorAudit.vue

@@ -11,7 +11,11 @@
       <el-button size="small" @click="loadData" :loading="loading">刷新</el-button>
     </div>
     <el-table :data="list" v-loading="loading" border stripe style="min-width:900px;" :max-height="tableHeight">
-      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="id" label="ID" width="60">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="nickname" label="昵称" width="120" />
       <el-table-column prop="realName" label="姓名" width="120" />
       <el-table-column prop="phone" label="手机号" width="140" />
@@ -45,10 +49,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getVendorList, reviewVendor } from '@/api/vendor'
 
 export default {
   name: 'VendorAudit',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -57,11 +63,13 @@ export default {
   data() {
     return {
       list: [], loading: false, keyword: '', statusFilter: 'pending',
+      sortableColumns: { id: 'ID' },
       rejectDialogVisible: false, rejectTarget: null, rejectReason: ''
     }
   },
   mounted() { this.loadData() },
   methods: {
+    loadList() { this.loadData() },
     vendorTypeLabel(t) { return { planner: '成长规划师', activity_provider: '活动提供商', product_supplier: '商品供应商' }[t] || t },
     statusType(s) { return { pending: 'warning', approved: 'success', rejected: 'danger' }[s] || 'info' },
     statusLabel(s) { return { pending: '待审核', approved: '已通过', rejected: '已拒绝' }[s] || s },
@@ -69,7 +77,9 @@ export default {
     async loadData() {
       this.loading = true
       try {
-        const res = await getVendorList({ status: this.statusFilter, keyword: this.keyword || undefined, page: 1, size: 50 })
+        const params = { status: this.statusFilter, keyword: this.keyword || undefined, page: 1, size: 50 }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getVendorList(params)
         this.list = res.data && res.data.records || res.data && res.data.list || []
       } catch (e) { this.$message.error('加载失败')
       } finally { this.loading = false }
@@ -98,4 +108,9 @@ export default {
 <style scoped>
 .filter-bar { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; padding: 12px; background: #fff; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.08); }
 .no-op { color: #ccc; }
+.sort-header { cursor: pointer; user-select: none; font-weight: 500; font-size: 12px; }
+.sort-header:hover { color: #409eff; }
+.sort-index { color: #fff; border-radius: 8px; font-size: 10px; padding: 0 4px; margin: 0 2px; }
+.sort-asc-badge { background: #f56c6c; }
+.sort-desc-badge { background: #409eff; }
 </style>

+ 24 - 5
cfc-web/src/views/admin/review/WithdrawAudit.vue

@@ -11,7 +11,11 @@
       <el-button size="small" @click="loadData" :loading="loading">刷新</el-button>
     </div>
     <el-table :data="list" v-loading="loading" border stripe style="min-width:900px;" :max-height="tableHeight">
-      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="id" label="ID" width="60">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column label="用户" width="120">
         <template slot-scope="{ row }">{{ row.nickname || row.userName || '-' }}</template>
       </el-table-column>
@@ -26,7 +30,11 @@
           <el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag>
         </template>
       </el-table-column>
-      <el-table-column prop="createdAt" label="申请时间" width="160" />
+      <el-table-column prop="createdAt" label="申请时间" width="160">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">申请时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column label="操作" width="180" fixed="right">
         <template slot-scope="{ row }">
           <template v-if="row.status === 'pending'">
@@ -51,10 +59,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getWithdrawalList, auditWithdrawal } from '@/api/admin'
 
 export default {
   name: 'WithdrawAudit',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -68,22 +78,26 @@ export default {
       statusFilter: '',
       page: 1,
       size: 20,
-      total: 0
+      total: 0,
+      sortableColumns: { id: 'ID', createdAt: '申请时间' }
     }
   },
   created() {
     this.loadData()
   },
   methods: {
+    loadList() { this.loadData() },
     async loadData() {
       this.loading = true
       try {
-        const res = await getWithdrawalList({
+        const params = {
           page: this.page,
           size: this.size,
           status: this.statusFilter || undefined,
           keyword: this.keyword || undefined
-        })
+        }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getWithdrawalList(params)
         if (res.code === 200) {
           this.list = res.data && res.data.records || res.data && res.data.list || []
           this.total = (res.data && res.data.total) || 0
@@ -134,4 +148,9 @@ export default {
 
 <style scoped>
 .filter-bar { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; padding: 12px; background: #fff; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.08); }
+.sort-header { cursor: pointer; user-select: none; font-weight: 500; font-size: 12px; }
+.sort-header:hover { color: #409eff; }
+.sort-index { color: #fff; border-radius: 8px; font-size: 10px; padding: 0 4px; margin: 0 2px; }
+.sort-asc-badge { background: #f56c6c; }
+.sort-desc-badge { background: #409eff; }
 </style>

+ 39 - 1
cfc-web/src/views/admin/supply-hierarchy/ChangeRequest.vue

@@ -23,7 +23,11 @@
             </div>
             
             <el-table :data="requests" stripe :max-height="tableHeight">
-                <el-table-column prop="id" label="ID" width="60"></el-table-column>
+                <el-table-column prop="id" label="ID" width="60">
+                    <template slot="header">
+                        <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+                    </template>
+                </el-table-column>
                 <el-table-column prop="systemId" label="体系ID" width="80"></el-table-column>
                 <el-table-column label="申请人" width="120">
                     <template slot-scope="scope">{{ scope.row.applicantNickname }} ({{ scope.row.applicantRealName }})</template>
@@ -41,6 +45,9 @@
                 </el-table-column>
                 <el-table-column prop="createdAt" label="申请时间" width="160">
                     <template slot-scope="scope">{{ scope.row.createdAt | parseTime }}</template>
+                    <template slot="header">
+                        <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">申请时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+                    </template>
                 </el-table-column>
                 <el-table-column label="操作" width="180">
                     <template slot-scope="scope">
@@ -67,10 +74,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { listAllSupplySystems, getSupplyHierarchyChangeRequests, reviewSupplyHierarchyChange } from '@/api/admin'
 
 export default {
     name: 'SupplyHierarchyChangeRequest',
+    mixins: [SortMixin],
     computed: {
       tableHeight() {
         return window.innerHeight - 300
@@ -80,6 +89,7 @@ export default {
         return {
             supplySystems: [],
             requests: [],
+            sortableColumns: { id: 'ID', createdAt: '申请时间' },
             filterForm: {
                 systemId: null,
                 status: null
@@ -102,6 +112,9 @@ export default {
         }
     },
     methods: {
+        loadList() {
+            this.loadRequests()
+        },
         async loadSupplySystems() {
             try {
                 const res = await listAllSupplySystems()
@@ -121,6 +134,9 @@ export default {
                 if (this.filterForm.status) {
                     params.status = this.filterForm.status
                 }
+                if (this.sortState.length > 0) {
+                    params.sort = this.sortState
+                }
                 const res = await getSupplyHierarchyChangeRequests(params)
                 if (res.data) {
                     this.requests = res.data
@@ -191,4 +207,26 @@ export default {
 .filter-section {
     margin-bottom: 20px;
 }
+.sort-header {
+    cursor: pointer;
+    user-select: none;
+    font-weight: 500;
+    font-size: 12px;
+}
+.sort-header:hover {
+    color: #409eff;
+}
+.sort-index {
+    color: #fff;
+    border-radius: 8px;
+    font-size: 10px;
+    padding: 0 4px;
+    margin: 0 2px;
+}
+.sort-asc-badge {
+    background: #f56c6c;
+}
+.sort-desc-badge {
+    background: #409eff;
+}
 </style>

+ 40 - 3
cfc-web/src/views/admin/supply-manage/List.vue

@@ -25,7 +25,11 @@
     </el-form>
 
       <el-table :data="suppliers" stripe v-loading="loading" :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="70"></el-table-column>
+        <el-table-column prop="id" label="ID" width="70">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="nickname" label="昵称" width="130"></el-table-column>
         <el-table-column prop="realName" label="真实姓名" width="120"></el-table-column>
         <el-table-column prop="phone" label="手机号" width="130"></el-table-column>
@@ -43,6 +47,9 @@
         </el-table-column>
         <el-table-column prop="createdAt" label="创建时间" width="170">
           <template slot-scope="scope">{{ scope.row.createdAt | parseTime('{y}-{m}-{d} {h}:{i}') }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="220" fixed="right">
           <template slot-scope="scope">
@@ -68,10 +75,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getSupplyManageList, deleteSupplyManage } from '@/api/admin'
 
 export default {
   name: 'SupplyManageList',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -81,6 +90,7 @@ export default {
     return {
       suppliers: [],
       loading: false,
+      sortableColumns: { id: 'ID', createdAt: '创建时间' },
       searchKeyword: '',
       searchVendorStatus: '',
       page: 1,
@@ -92,15 +102,20 @@ export default {
     this.loadData()
   },
   methods: {
+    loadList() {
+      this.loadData()
+    },
     async loadData() {
       this.loading = true
       try {
-        const res = await getSupplyManageList({
+        const params = {
           keyword: this.searchKeyword,
           vendorStatus: this.searchVendorStatus,
           page: this.page,
           size: this.pageSize
-        })
+        }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getSupplyManageList(params)
         if (res.data) {
           this.suppliers = res.data.list || []
           this.total = res.data.total || 0
@@ -164,4 +179,26 @@ export default {
 .supply-manage-list {
   padding: 20px;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-index {
+  color: #fff;
+  border-radius: 8px;
+  font-size: 10px;
+  padding: 0 4px;
+  margin: 0 2px;
+}
+.sort-asc-badge {
+  background: #f56c6c;
+}
+.sort-desc-badge {
+  background: #409eff;
+}
 </style>

+ 37 - 3
cfc-web/src/views/admin/supply-system/List.vue

@@ -24,7 +24,11 @@
       </el-form>
 
       <el-table :data="systems" stripe v-loading="loading" :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="80"></el-table-column>
+        <el-table-column prop="id" label="ID" width="80">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="name" label="体系名称" min-width="150"></el-table-column>
         <el-table-column prop="settlementPeriodDays" label="账期(天)" width="100"></el-table-column>
         <el-table-column prop="platformProfitRate" label="平台留利" width="100">
@@ -66,10 +70,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getSupplySystemList, toggleSupplySystemStatus } from '@/api/admin'
 
 export default {
   name: 'SupplySystemList',
+  mixins: [SortMixin],
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -79,6 +85,7 @@ export default {
     return {
       systems: [],
       loading: false,
+      sortableColumns: { id: 'ID' },
       searchKeyword: '',
       searchStatus: '',
       page: 1,
@@ -90,15 +97,20 @@ export default {
     this.loadData()
   },
   methods: {
+    loadList() {
+      this.loadData()
+    },
     async loadData() {
       this.loading = true
       try {
-        const res = await getSupplySystemList({
+        const params = {
           keyword: this.searchKeyword,
           status: this.searchStatus,
           page: this.page,
           size: this.pageSize
-        })
+        }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getSupplySystemList(params)
         if (res.data) {
           this.systems = res.data.records || res.data.list || []
           this.total = res.data.total || 0
@@ -130,4 +142,26 @@ export default {
 .supply-system-list {
   padding: 20px;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-index {
+  color: #fff;
+  border-radius: 8px;
+  font-size: 10px;
+  padding: 0 4px;
+  margin: 0 2px;
+}
+.sort-asc-badge {
+  background: #f56c6c;
+}
+.sort-desc-badge {
+  background: #409eff;
+}
 </style>

+ 24 - 4
cfc-web/src/views/health/HealthReportAudit.vue

@@ -18,6 +18,9 @@
       </el-table-column>
       <el-table-column label="上传时间" width="160">
         <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">上传时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+        </template>
       </el-table-column>
       <el-table-column label="过期时间" width="160">
         <template slot-scope="{ row }">{{ formatTime(row.expireAt) }}</template>
@@ -110,10 +113,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { searchUsers } from '@/api/admin'
 
 export default {
   name: 'HealthReportAudit',
+  mixins: [SortMixin],
   data() {
     return {
       list: [],
@@ -123,7 +128,8 @@ export default {
       total: 0,
       detailVisible: false,
       detail: null,
-      activeTab: 'indicators'
+      activeTab: 'indicators',
+      sortableColumns: { createdAt: '上传时间' }
     }
   },
   computed: {
@@ -156,9 +162,9 @@ export default {
         const baseURL = process.env.VUE_APP_BASE_API || ''
         const token = localStorage.getItem('token')
         const axios = this.$axios || (await import('axios')).default
-        const res = await axios.post(baseURL + '/api/health/report/audit/pending', {
-          page: this.page, size: this.size
-        }, {
+        var params = { page: this.page, size: this.size }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await axios.post(baseURL + '/api/health/report/audit/pending', params, {
           headers: { Authorization: 'Bearer ' + token }
         })
         const records = res.data?.data || []
@@ -278,4 +284,18 @@ export default {
   margin-top: 16px;
   text-align: right;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+.sort-tag {
+  margin-left: 8px;
+  cursor: pointer;
+  white-space: nowrap;
+}
 </style>

+ 29 - 4
cfc-web/src/views/nutritionist/HealthCheckins.vue

@@ -3,34 +3,47 @@
     <el-card>
       <div slot="header"><span>健康打卡</span></div>
       <el-table :data="checkins" v-loading="loading" border stripe :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="80" />
+        <el-table-column prop="id" label="ID" width="80">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="childName" label="孩子" width="120" />
         <el-table-column prop="checkinType" label="打卡类型" width="120" />
         <el-table-column prop="note" label="备注" />
-        <el-table-column prop="createdAt" label="打卡时间" width="180" />
+        <el-table-column prop="createdAt" label="打卡时间" width="180">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">打卡时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
+        </el-table-column>
       </el-table>
     </el-card>
   </div>
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 export default {
+  mixins: [SortMixin],
   name: 'HealthCheckins',
   computed: {
     tableHeight() {
       return window.innerHeight - 280
     }
   },
-  data() { return { loading: false, checkins: [] } },
+  data() { return { loading: false, checkins: [], sortableColumns: { id: 'ID', createdAt: '打卡时间' } } },
   created() { this.loadCheckins() },
   methods: {
+    loadList() { this.loadCheckins() },
     async loadCheckins() {
       this.loading = true
       try {
         const baseURL = process.env.VUE_APP_BASE_API || ''
         const token = localStorage.getItem('token')
         const axios = this.$axios || (await import('axios')).default
-        const res = await axios.post(baseURL + '/api/health/checkin/list', {}, {
+        const params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await axios.post(baseURL + '/api/health/checkin/list', params, {
           headers: { Authorization: 'Bearer ' + token }
         })
         this.checkins = res.data?.data || []
@@ -43,3 +56,15 @@ export default {
   }
 }
 </script>
+
+<style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+</style>

+ 29 - 4
cfc-web/src/views/nutritionist/HealthIndicators.vue

@@ -3,7 +3,11 @@
     <el-card>
       <div slot="header"><span>健康指标</span></div>
       <el-table :data="indicators" v-loading="loading" border stripe :max-height="tableHeight" :cell-style="cellStyle">
-        <el-table-column prop="id" label="ID" width="80" />
+        <el-table-column prop="id" label="ID" width="80">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="childName" label="孩子" width="120" />
         <el-table-column prop="indicatorName" label="指标名称" width="120">
           <template slot-scope="{ row }">
@@ -31,30 +35,39 @@
             <span v-else style="color: #999; font-size: 12px">—</span>
           </template>
         </el-table-column>
-        <el-table-column prop="recordedAt" label="记录时间" width="180" />
+        <el-table-column prop="recordedAt" label="记录时间" width="180">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('recordedAt', $event)">记录时间<span v-if="sortIndex('recordedAt') !== null" class="sort-index" :class="sortClass('recordedAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('recordedAt') }}</span>{{ sortIcon('recordedAt') }}</span>
+          </template>
+        </el-table-column>
       </el-table>
     </el-card>
   </div>
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 export default {
+  mixins: [SortMixin],
   name: 'HealthIndicators',
   computed: {
     tableHeight() {
       return window.innerHeight - 280
     }
   },
-  data() { return { loading: false, indicators: [] } },
+  data() { return { loading: false, indicators: [], sortableColumns: { id: 'ID', recordedAt: '记录时间' } } },
   created() { this.loadIndicators() },
   methods: {
+    loadList() { this.loadIndicators() },
     async loadIndicators() {
       this.loading = true
       try {
         const baseURL = process.env.VUE_APP_BASE_API || ''
         const token = localStorage.getItem('token')
         const axios = this.$axios || (await import('axios')).default
-        const res = await axios.post(baseURL + '/api/health/indicator/list', {}, {
+        const params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await axios.post(baseURL + '/api/health/indicator/list', params, {
           headers: { Authorization: 'Bearer ' + token }
         })
         this.indicators = res.data?.data || []
@@ -81,3 +94,15 @@ export default {
   }
 }
 </script>
+
+<style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+</style>

+ 29 - 6
cfc-web/src/views/nutritionist/HealthReports.vue

@@ -5,7 +5,11 @@
         <span>健康报告</span>
       </div>
       <el-table :data="reports" v-loading="loading" border stripe :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="70" />
+        <el-table-column prop="id" label="ID" width="70">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="childName" label="孩子" width="100" />
         <el-table-column prop="reportType" label="报告类型" width="100" />
         <el-table-column prop="overallScore" label="综合评分" width="100">
@@ -20,7 +24,11 @@
         <el-table-column prop="chronicDiseaseScore" label="慢病" width="80" />
         <el-table-column prop="gutAge" label="肠道年龄" width="80" />
         <el-table-column prop="gutType" label="肠道类型" width="100" />
-        <el-table-column prop="reportDate" label="报告日期" width="160" />
+        <el-table-column prop="reportDate" label="报告日期" width="160">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('reportDate', $event)">报告日期<span v-if="sortIndex('reportDate') !== null" class="sort-index" :class="sortClass('reportDate') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('reportDate') }}</span>{{ sortIcon('reportDate') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="营养缺乏" width="150">
           <template slot-scope="{ row }">
             <el-tag v-if="row.deficiencyCount > 0" type="danger" size="small">
@@ -47,7 +55,9 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 export default {
+  mixins: [SortMixin],
   name: 'HealthReports',
   computed: {
     tableHeight() {
@@ -55,19 +65,20 @@ export default {
     }
   },
   data() {
-    return { loading: false, reports: [], page: 1, size: 20, total: 0 }
+    return { loading: false, reports: [], page: 1, size: 20, total: 0, sortableColumns: { id: 'ID', reportDate: '报告日期' } }
   },
   created() { this.loadReports() },
   methods: {
+    loadList() { this.loadReports() },
     async loadReports() {
       this.loading = true
       try {
         const baseURL = process.env.VUE_APP_BASE_API || ''
         const token = localStorage.getItem('token')
         const axios = this.$axios || (await import('axios')).default
-        const res = await axios.post(baseURL + '/api/health/report/admin-list', {
-          page: this.page, size: this.size
-        }, {
+        const params = { page: this.page, size: this.size }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await axios.post(baseURL + '/api/health/report/admin-list', params, {
           headers: { Authorization: 'Bearer ' + token }
         })
         const data = res.data?.data || {}
@@ -112,3 +123,15 @@ export default {
   }
 }
 </script>
+
+<style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+</style>

+ 31 - 3
cfc-web/src/views/nutritionist/NutritionMappings.vue

@@ -6,13 +6,21 @@
         <el-button type="primary" size="mini" style="float:right" @click="openCreateDialog">新增映射</el-button>
       </div>
       <el-table :max-height="tableHeight" :data="mappings" v-loading="loading" border stripe>
-        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="id" label="ID" width="60">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="indicatorNamePattern" label="指标名模式" width="180" />
         <el-table-column prop="nutrient" label="营养素" width="100" />
         <el-table-column prop="severityWeight" label="权重" width="80" />
         <el-table-column prop="deficiencySymptoms" label="缺乏症状" min-width="150" show-overflow-tooltip />
         <el-table-column prop="foodSuggestions" label="推荐食物" min-width="180" show-overflow-tooltip />
-        <el-table-column prop="sortOrder" label="排序" width="80" />
+        <el-table-column prop="sortOrder" label="排序" width="80">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('sortOrder', $event)">排序<span v-if="sortIndex('sortOrder') !== null" class="sort-index" :class="sortClass('sortOrder') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('sortOrder') }}</span>{{ sortIcon('sortOrder') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="操作" width="200" fixed="right">
           <template slot-scope="{ row }">
             <el-button size="mini" type="primary" @click="handleEdit(row)">编辑</el-button>
@@ -62,7 +70,9 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 export default {
+  mixins: [SortMixin],
   name: 'NutritionMappings',
   computed: {
     tableHeight() {
@@ -75,6 +85,7 @@ export default {
       mappings: [],
       dialogVisible: false,
       dialogTitle: '新增映射',
+      sortableColumns: { id: 'ID', sortOrder: '排序' },
       form: {
         id: null,
         indicatorNamePattern: '',
@@ -91,13 +102,18 @@ export default {
     this.loadMappings()
   },
   methods: {
+    loadList() {
+      this.loadMappings()
+    },
     async loadMappings() {
       this.loading = true
       try {
         const baseURL = process.env.VUE_APP_BASE_API || ''
         const token = localStorage.getItem('token')
         const axios = this.$axios || (await import('axios')).default
-        const res = await axios.post(baseURL + '/api/health/nutrition/mappings', {}, {
+        const params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await axios.post(baseURL + '/api/health/nutrition/mappings', params, {
           headers: { Authorization: 'Bearer ' + token }
         })
         this.mappings = res.data?.data || []
@@ -161,3 +177,15 @@ export default {
   }
 }
 </script>
+
+<style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+</style>

+ 31 - 4
cfc-web/src/views/teacher/FamilyList.vue

@@ -6,10 +6,18 @@
         <el-input v-model="keyword" placeholder="搜索家庭..." prefix-icon="el-icon-search" style="width:200px" clearable @keyup.enter.native="handleSearch" @clear="handleSearch" />
       </div>
     <el-table :data="filteredFamilies" v-loading="loading" border stripe :max-height="tableHeight">
-      <el-table-column prop="id" label="ID" width="80" />
+      <el-table-column prop="id" label="ID" width="80">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)" @dblclick.stop="onSortToggle('id')">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column prop="familyName" label="家庭名称" />
       <el-table-column prop="memberCount" label="成员数" width="100" />
-      <el-table-column prop="createdAt" label="绑定时间" width="180" />
+      <el-table-column prop="createdAt" label="绑定时间" width="180">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('createdAt', $event)" @dblclick.stop="onSortToggle('createdAt')">绑定时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column label="操作" width="200" fixed="right">
         <template slot-scope="{ row }">
           <el-button size="mini" type="primary" @click="handleEdit(row)">编辑</el-button>
@@ -30,10 +38,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 export default {
+  mixins: [SortMixin],
   name: 'FamilyList',
   data() {
-    return { loading: false, families: [], keyword: '' }
+    return { loading: false, families: [], keyword: '', sortableColumns: { id: 'ID', createdAt: '绑定时间' } }
   },
   computed: {
     tableHeight() {
@@ -52,13 +62,18 @@ export default {
   methods: {
     handleSearch() {
     },
+    loadList() {
+      this.loadFamilies()
+    },
     async loadFamilies() {
       this.loading = true
       try {
         const token = localStorage.getItem('token')
         const baseURL = process.env.VUE_APP_BASE_API || ''
         const axios = this.$axios || (await import('axios')).default
-        const res = await axios.post(baseURL + '/api/guide/family/list', {}, {
+        const params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await axios.post(baseURL + '/api/guide/family/list', params, {
           headers: { Authorization: 'Bearer ' + token }
         })
         this.families = res.data?.data || []
@@ -84,3 +99,15 @@ export default {
   }
 }
 </script>
+
+<style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+</style>

+ 31 - 4
cfc-web/src/views/teacher/GrowthPlans.vue

@@ -6,20 +6,30 @@
         <el-input v-model="keyword" placeholder="搜索成长计划..." prefix-icon="el-icon-search" style="width:200px; float: right;" clearable @keyup.enter.native="handleSearch" @clear="handleSearch" />
       </div>
       <el-table :data="filteredPlans" v-loading="loading" border stripe :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="80" />
+        <el-table-column prop="id" label="ID" width="80">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)" @dblclick.stop="onSortToggle('id')">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="childName" label="孩子" width="120" />
         <el-table-column prop="title" label="计划标题" />
         <el-table-column prop="status" label="状态" width="100" />
-        <el-table-column prop="createdAt" label="创建时间" width="180" />
+        <el-table-column prop="createdAt" label="创建时间" width="180">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)" @dblclick.stop="onSortToggle('createdAt')">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
+        </el-table-column>
       </el-table>
     </el-card>
   </div>
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 export default {
+  mixins: [SortMixin],
   name: 'GrowthPlans',
-  data() { return { loading: false, plans: [], keyword: '' } },
+  data() { return { loading: false, plans: [], keyword: '', sortableColumns: { id: 'ID', createdAt: '创建时间' } } },
   computed: {
     tableHeight() {
       return window.innerHeight - 300
@@ -36,13 +46,18 @@ export default {
   created() { this.loadPlans() },
   methods: {
     handleSearch() {},
+    loadList() {
+      this.loadPlans()
+    },
     async loadPlans() {
       this.loading = true
       try {
         const baseURL = process.env.VUE_APP_BASE_API || ''
         const token = localStorage.getItem('token')
         const axios = this.$axios || (await import('axios')).default
-        const res = await axios.post(baseURL + '/api/growth/plan/list', {}, {
+        const params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await axios.post(baseURL + '/api/growth/plan/list', params, {
           headers: { Authorization: 'Bearer ' + token }
         })
         this.plans = res.data?.data || []
@@ -55,3 +70,15 @@ export default {
   }
 }
 </script>
+
+<style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+</style>

+ 30 - 3
cfc-web/src/views/teacher/GrowthRecords.vue

@@ -13,7 +13,11 @@
         </el-select>
       </div>
       <el-table :data="filteredRecords" v-loading="loading" border stripe :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="70" />
+        <el-table-column prop="id" label="ID" width="70">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)" @dblclick.stop="onSortToggle('id')">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="childName" label="孩子" width="100" />
         <el-table-column prop="sourceType" label="来源" width="100">
           <template slot-scope="scope">
@@ -35,21 +39,28 @@
             <span v-else>-</span>
           </template>
         </el-table-column>
-        <el-table-column prop="createdAt" label="创建时间" width="170" />
+        <el-table-column prop="createdAt" label="创建时间" width="170">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)" @dblclick.stop="onSortToggle('createdAt')">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
+        </el-table-column>
       </el-table>
     </el-card>
   </div>
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 export default {
+  mixins: [SortMixin],
   name: 'GrowthRecords',
   data() {
     return {
       loading: false,
       records: [],
       sourceFilter: '',
-      keyword: ''
+      keyword: '',
+      sortableColumns: { id: 'ID', createdAt: '创建时间' }
     }
   },
   computed: {
@@ -78,6 +89,9 @@ export default {
       return map[type] || 'info'
     },
     handleSearch() {},
+    loadList() {
+      this.loadRecords()
+    },
     async loadRecords() {
       this.loading = true
       try {
@@ -88,6 +102,7 @@ export default {
         if (this.sourceFilter) {
           params.sourceType = this.sourceFilter
         }
+        if (this.sortState.length > 0) params.sort = this.sortState
         var res = await axios.post(baseURL + '/api/growth/record/list', params, {
           headers: { Authorization: 'Bearer ' + token }
         })
@@ -101,3 +116,15 @@ export default {
   }
 }
 </script>
+
+<style scoped>
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
+</style>

+ 29 - 4
cfc-web/src/views/teacher/HealthPlanReview.vue

@@ -16,7 +16,11 @@
     </div>
 
     <el-table :data="plans" border stripe style="width:100%" v-loading="loading">
-      <el-table-column prop="id" label="方案ID" width="80" />
+      <el-table-column prop="id" label="方案ID" width="80">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('id', $event)" @dblclick.stop="onSortToggle('id')">方案ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column label="家庭成员" width="150">
         <template slot-scope="{ row }">{{ row.memberName || row.memberIds }}</template>
       </el-table-column>
@@ -36,7 +40,11 @@
       <el-table-column prop="reviewComment" label="审核意见" width="150">
         <template slot-scope="{ row }">{{ row.reviewComment || '-' }}</template>
       </el-table-column>
-      <el-table-column prop="createdAt" label="创建时间" width="160" />
+      <el-table-column prop="createdAt" label="创建时间" width="160">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('createdAt', $event)" @dblclick.stop="onSortToggle('createdAt')">创建时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column label="操作" width="220">
         <template slot-scope="{ row }">
           <el-button size="mini" type="primary" @click="showEdit(row)">编辑</el-button>
@@ -99,9 +107,11 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getPendingReviewPlans, updatePlanContent, approvePlan, rejectPlan, getHealthPlanDetail } from '@/api/healthPlan'
 
 export default {
+  mixins: [SortMixin],
   name: 'HealthPlanReview',
   data() {
     return {
@@ -117,6 +127,7 @@ export default {
       currentPlan: null,
       editForm: { planContent: '', reviewComment: '' },
       rejectForm: { planId: null, comment: '' },
+      sortableColumns: { id: '方案ID', createdAt: '创建时间' },
       editTasks: { sections: [] },
       actionTypes: [
         { value: 'buy', label: '购买' },
@@ -151,13 +162,18 @@ export default {
         if (json.code === 200) this.families = json.data || []
       } catch(e) {}
     },
+    loadList() {
+      this.loadPlans()
+    },
     async loadPlans() {
       this.loading = true
       try {
-        const res = await getPendingReviewPlans({
+        const params = {
           familyId: this.familyFilter || null,
           status: this.statusFilter || null
-        })
+        }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await getPendingReviewPlans(params)
         if (res.code === 200) this.plans = res.data || []
       } catch(e) {
         this.$message.error('加载失败')
@@ -290,4 +306,13 @@ export default {
 .page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
 .page-header h2 { margin: 0; font-size: 20px; }
 .filters { display: flex; align-items: center; }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 26 - 2
cfc-web/src/views/teacher/ProxyReport.vue

@@ -83,7 +83,11 @@
         <span class="card-title">📁 代上传历史记录</span>
       </div>
       <el-table :data="reportList" border stripe v-loading="listLoading" :max-height="tableHeight">
-        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="id" label="ID" width="60">
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('id', $event)" @dblclick.stop="onSortToggle('id')">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
+          </template>
+        </el-table-column>
         <el-table-column label="报告对象" width="120">
           <template slot-scope="{ row }">{{ row.subjectName || row.userName || '-' }}</template>
         </el-table-column>
@@ -99,6 +103,9 @@
         <el-table-column prop="remark" label="备注" show-overflow-tooltip />
         <el-table-column prop="createdAt" label="上传时间" width="160">
           <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+          <template slot="header">
+            <span class="sort-header" @click.stop="onSortClick('createdAt', $event)" @dblclick.stop="onSortToggle('createdAt')">上传时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
+          </template>
         </el-table-column>
         <el-table-column label="操作" width="120" fixed="right">
           <template slot-scope="{ row }">
@@ -143,11 +150,13 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { proxyParseReport, proxyConfirmReport, listHealthReports, getHealthReportDetail } from '@/api/admin'
 import { getFamilyList } from '@/api/admin'
 import { getFamilyMemberList } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   name: 'ProxyReport',
   data() {
     return {
@@ -164,6 +173,7 @@ export default {
       page: 1,
       size: 20,
       total: 0,
+      sortableColumns: { id: 'ID', createdAt: '上传时间' },
       uploadForm: {
         familyId: null,
         subjectId: null,
@@ -182,6 +192,9 @@ export default {
     this.loadReports()
   },
   methods: {
+    loadList() {
+      this.loadReports()
+    },
     async loadFamilies() {
       try {
         const res = await getFamilyList({ page: 1, size: 200 })
@@ -267,7 +280,9 @@ export default {
     async loadReports() {
       this.listLoading = true
       try {
-        const res = await listHealthReports({ page: this.page, size: this.size })
+        const params = { page: this.page, size: this.size }
+        if (this.sortState.length > 0) params.sort = this.sortState
+        const res = await listHealthReports(params)
         if (res.code === 200) {
           this.reportList = res.data && res.data.records ? res.data.records : (res.data || [])
           this.total = res.data && res.data.total ? res.data.total : this.reportList.length
@@ -330,4 +345,13 @@ export default {
   margin-top: 20px;
   text-align: right;
 }
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

+ 24 - 2
cfc-web/src/views/teacher/TeacherAssessment.vue

@@ -7,7 +7,11 @@
       </div>
 
     <el-table :data="filteredConfigs" border stripe v-loading="loading" :max-height="tableHeight">
-      <el-table-column prop="familyId" label="家庭ID" width="80" />
+      <el-table-column prop="familyId" label="家庭ID" width="80">
+        <template slot="header">
+          <span class="sort-header" @click.stop="onSortClick('familyId', $event)">家庭ID<span v-if="sortIndex('familyId') !== null" class="sort-index" :class="sortClass('familyId') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('familyId') }}</span>{{ sortIcon('familyId') }}</span>
+        </template>
+      </el-table-column>
       <el-table-column label="家庭名称" width="120">
         <template slot-scope="scope">
           家庭{{ scope.row.familyId }}
@@ -91,10 +95,12 @@
 </template>
 
 <script>
+import SortMixin from '@/mixins/SortMixin'
 import { getGuideFamilyAssessmentList, enableFamilyAssessment, disableFamilyAssessment, getGuideAssessmentMaterials } from '@/api/assessment'
 import { getFamilyList } from '@/api/admin'
 
 export default {
+  mixins: [SortMixin],
   name: 'TeacherAssessment',
   data() {
     return {
@@ -105,6 +111,7 @@ export default {
       availableFamilies: [],
       enableDialogVisible: false,
       editDialogVisible: false,
+      sortableColumns: { familyId: '家庭ID' },
       enableForm: {
         familyId: null,
         materialId: null,
@@ -135,11 +142,16 @@ export default {
   methods: {
     handleSearch() {
     },
+    loadList() {
+      this.loadData()
+    },
     async loadData() {
       this.loading = true
       try {
+        const params = {}
+        if (this.sortState.length > 0) params.sort = this.sortState
         const [configsRes, familiesRes] = await Promise.all([
-          getGuideFamilyAssessmentList(),
+          getGuideFamilyAssessmentList(params),
           getFamilyList({})
         ])
         this.familyConfigs = (configsRes.data && configsRes.data.records) || configsRes.data || []
@@ -224,4 +236,14 @@ export default {
   font-weight: 600;
   color: #303133;
 }
+
+.sort-header {
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 12px;
+}
+.sort-header:hover {
+  color: #409eff;
+}
 </style>

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff