Browse Source

feat: 增强管理后台页面功能

charts: 命盘分析页面重构。commissions: 佣金查看页优化。dashboard: 仪表盘数据展示增强。orders: 订单管理页功能完善。settings: 系统配置页支持超级会员字段。users: 用户管理页适配。withdraw: 提现管理占位页。

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
liaoxg 3 tháng trước cách đây
mục cha
commit
6880bac822

+ 10 - 3
admin/src/views/charts/index.vue

@@ -2,7 +2,7 @@
   <div class="charts-page">
     <el-card shadow="hover">
       <template #header><span>分析记录</span></template>
-      <el-table :data="records" v-loading="loading" stripe>
+      <el-table :data="pagedRecords" v-loading="loading" stripe>
         <el-table-column prop="id" label="ID" width="60" />
         <el-table-column prop="userId" label="用户ID" width="80" />
         <el-table-column prop="phoneNumber" label="手机号" width="130" />
@@ -22,7 +22,7 @@
         </el-table-column>
       </el-table>
       <div class="pagination-wrap">
-        <el-pagination background layout="total, prev, pager, next" :total="records.length" :page-size="20" />
+        <el-pagination background layout="total, sizes, prev, pager, next" v-model:current-page="currentPage" v-model:page-size="pageSize" :total="records.length" :page-sizes="[10, 20, 50, 100]" />
       </div>
     </el-card>
 
@@ -49,11 +49,18 @@
 </template>
 
 <script setup lang="ts">
-import { ref } from 'vue'
+import { ref, computed } from 'vue'
 import request from '@/utils/request'
 
 const loading = ref(false)
 const records = ref<any[]>([])
+const currentPage = ref(1)
+const pageSize = ref(20)
+
+const pagedRecords = computed(() => {
+  const start = (currentPage.value - 1) * pageSize.value
+  return records.value.slice(start, start + pageSize.value)
+})
 const dialogVisible = ref(false)
 const currentRecord = ref<any>(null)
 

+ 9 - 2
admin/src/views/commissions/index.vue

@@ -29,7 +29,7 @@
 
     <el-card shadow="hover">
       <template #header><span>佣金明细</span></template>
-      <el-table :data="commissions" v-loading="loading" stripe>
+      <el-table :data="pagedCommissions" v-loading="loading" stripe>
         <el-table-column prop="id" label="ID" width="60" />
         <el-table-column prop="orderId" label="订单ID" width="80" />
         <el-table-column prop="fromUserId" label="来源用户" width="100" />
@@ -50,7 +50,7 @@
         <el-table-column prop="createdAt" label="时间" width="110" />
       </el-table>
       <div class="pagination-wrap">
-        <el-pagination background layout="total, prev, pager, next" :total="commissions.length" :page-size="20" />
+        <el-pagination background layout="total, sizes, prev, pager, next" v-model:current-page="currentPage" v-model:page-size="pageSize" :total="commissions.length" :page-sizes="[10, 20, 50, 100]" />
       </div>
     </el-card>
   </div>
@@ -62,6 +62,13 @@ import request from '@/utils/request'
 
 const loading = ref(false)
 const commissions = ref<any[]>([])
+const currentPage = ref(1)
+const pageSize = ref(20)
+
+const pagedCommissions = computed(() => {
+  const start = (currentPage.value - 1) * pageSize.value
+  return commissions.value.slice(start, start + pageSize.value)
+})
 
 const settledTotal = computed(() => {
   const total = commissions.value.filter(c => c.status === 'settled').reduce((s, c) => s + c.amount, 0)

+ 71 - 23
admin/src/views/dashboard/index.vue

@@ -14,7 +14,7 @@
     <el-row :gutter="20" class="chart-row">
       <el-col :span="14">
         <el-card shadow="hover">
-          <template #header><span>月新增用户</span></template>
+          <template #header><span>核心指标对比</span></template>
           <div ref="lineChartRef" style="height: 300px"></div>
         </el-card>
       </el-col>
@@ -48,7 +48,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, computed, onMounted, nextTick } from 'vue'
+import { ref, computed, onMounted, nextTick, watch } from 'vue'
 import * as echarts from 'echarts'
 import request from '@/utils/request'
 
@@ -78,35 +78,83 @@ async function loadStats() {
 }
 
 function initCharts() {
-  // Line chart — monthly signup demo
+  // Bar chart — core metrics comparison from stats API
   if (lineChartRef.value) {
     const chart = echarts.init(lineChartRef.value)
-    chart.setOption({
-      xAxis: { type: 'category', data: ['1月','2月','3月','4月','5月','6月'] },
-      yAxis: { type: 'value' },
-      series: [{ data: [18, 25, 30, 42, 55, 48], type: 'line', smooth: true, areaStyle: {} }],
-      tooltip: { trigger: 'axis' },
-      grid: { left: '5%', right: '5%', bottom: '10%' },
-    })
+    const s = stats.value
+    const hasData = s.totalUsers != null || s.vipUsers != null || s.totalOrders != null || s.totalCharts != null
+    if (!hasData) {
+      chart.setOption({
+        graphic: {
+          type: 'text',
+          left: 'center',
+          top: 'middle',
+          style: { text: '暂无数据', fontSize: 16, fill: '#999' },
+        },
+      })
+    } else {
+      chart.setOption({
+        xAxis: { type: 'category', data: ['总用户', 'VIP用户', '总订单', '总分析'] },
+        yAxis: { type: 'value' },
+        series: [{
+          data: [
+            s.totalUsers ?? 0,
+            s.vipUsers ?? 0,
+            s.totalOrders ?? 0,
+            s.totalCharts ?? 0,
+          ],
+          type: 'bar',
+          barWidth: '50%',
+          itemStyle: {
+            borderRadius: [4, 4, 0, 0],
+            color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+              { offset: 0, color: '#409EFF' },
+              { offset: 1, color: '#79bbff' },
+            ]),
+          },
+        }],
+        tooltip: { trigger: 'axis' },
+        grid: { left: '10%', right: '5%', bottom: '15%' },
+      })
+    }
   }
-  // Pie chart — order status demo
+  // Pie chart — order status from recentOrders
   if (pieChartRef.value) {
     const chart = echarts.init(pieChartRef.value)
-    chart.setOption({
-      series: [{
-        type: 'pie',
-        data: [
-          { name: '已支付', value: 68 },
-          { name: '待支付', value: 18 },
-        ],
-        radius: ['40%', '70%'],
-        label: { show: true, formatter: '{b}: {d}%' },
-      }],
-      tooltip: { trigger: 'item' },
-    })
+    const orders = recentOrders.value ?? []
+    const paidOrders = orders.filter((o: any) => o.status === 'paid').length
+    const pendingOrders = orders.filter((o: any) => o.status !== 'paid').length
+    if (orders.length === 0) {
+      chart.setOption({
+        graphic: {
+          type: 'text',
+          left: 'center',
+          top: 'middle',
+          style: { text: '暂无订单数据', fontSize: 16, fill: '#999' },
+        },
+      })
+    } else {
+      chart.setOption({
+        series: [{
+          type: 'pie',
+          data: [
+            { name: '已支付', value: paidOrders },
+            { name: '待支付', value: pendingOrders },
+          ],
+          radius: ['40%', '70%'],
+          label: { show: true, formatter: '{b}: {d}%' },
+        }],
+        tooltip: { trigger: 'item' },
+      })
+    }
   }
 }
 
+// Re-render charts when stats data changes
+watch(stats, () => {
+  nextTick(initCharts)
+}, { deep: true })
+
 onMounted(async () => {
   await loadStats()
   nextTick(initCharts)

+ 40 - 7
admin/src/views/orders/index.vue

@@ -10,15 +10,30 @@
               <el-option label="已支付" value="paid" />
               <el-option label="待支付" value="pending" />
             </el-select>
+            <el-select v-model="productTypeFilter" placeholder="产品类型" style="width: 120px" clearable>
+              <el-option label="全部" value="" />
+              <el-option label="能量师" value="practitioner" />
+              <el-option label="C端年费" value="annual" />
+            </el-select>
           </div>
         </div>
       </template>
-      <el-table :data="orders" v-loading="loading" stripe>
+      <el-table :data="pagedOrders" v-loading="loading" stripe>
         <el-table-column prop="id" label="ID" width="60" />
         <el-table-column prop="outTradeNo" label="订单号" width="200" />
         <el-table-column prop="userId" label="用户ID" width="80" />
-        <el-table-column label="金额" width="100">
-          <template #default="{ row }">{{ (row.totalFee / 100).toFixed(2) }}元</template>
+        <el-table-column label="金额" width="130">
+          <template #default="{ row }">
+            {{ (row.totalFee / 100).toFixed(2) }}元
+            <el-tag v-if="row.isSeedPrice" size="small" type="warning" style="margin-left: 4px">🌱 种子价</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="产品类型" width="120">
+          <template #default="{ row }">
+            <el-tag :type="row.productType === 'practitioner' ? 'primary' : 'success'" size="small">
+              {{ row.productType === 'practitioner' ? '能量师' : 'C端年费' }}
+            </el-tag>
+          </template>
         </el-table-column>
         <el-table-column label="状态" width="100">
           <template #default="{ row }">
@@ -32,23 +47,41 @@
         <el-table-column prop="paidAt" label="支付时间" width="110" />
       </el-table>
       <div class="pagination-wrap">
-        <el-pagination background layout="total, prev, pager, next" :total="orders.length" :page-size="20" />
+        <el-pagination background layout="total, sizes, prev, pager, next" :total="filteredOrders.length" v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[10, 20, 50, 100]" />
       </div>
     </el-card>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, computed } from 'vue'
+import { ref, computed, watch } from 'vue'
 import request from '@/utils/request'
 
 const loading = ref(false)
 const statusFilter = ref('')
+const productTypeFilter = ref('')
+const currentPage = ref(1)
+const pageSize = ref(20)
 const orders = ref<any[]>([])
 
+watch([statusFilter, productTypeFilter], () => {
+  currentPage.value = 1
+})
+
 const filteredOrders = computed(() => {
-  if (!statusFilter.value) return orders.value
-  return orders.value.filter((o: any) => o.status === statusFilter.value)
+  let result = orders.value
+  if (statusFilter.value) {
+    result = result.filter((o: any) => o.status === statusFilter.value)
+  }
+  if (productTypeFilter.value) {
+    result = result.filter((o: any) => o.productType === productTypeFilter.value)
+  }
+  return result
+})
+
+const pagedOrders = computed(() => {
+  const start = (currentPage.value - 1) * pageSize.value
+  return filteredOrders.value.slice(start, start + pageSize.value)
 })
 
 async function loadOrders() {

+ 41 - 7
admin/src/views/settings/index.vue

@@ -56,6 +56,21 @@
               当前已使用:{{ seedUsedCount }} / {{ configValues['pricing.practitioner.seed_limit'] }}
             </div>
           </div>
+          <div class="field-item">
+            <div class="field-main">
+              <span class="field-label">种子价有效期</span>
+              <el-date-picker
+                v-model="configValues['pricing.practitioner.seed_period_end']"
+                type="datetime"
+                format="YYYY-MM-DD HH:mm:ss"
+                value-format="YYYY-MM-DD HH:mm:ss"
+                placeholder="选择种子价有效期"
+              />
+            </div>
+            <div class="field-hint">
+              {{ seedPeriodHint }}
+            </div>
+          </div>
           <div class="field-item">
             <div class="field-main">
               <span class="field-label">C端年费</span>
@@ -266,10 +281,11 @@ import request from '@/utils/request'
 
 /* ──────────── Constants ──────────── */
 
-const VALUE_TYPES: Record<string, 'price' | 'percent' | 'plain'> = {
+const VALUE_TYPES: Record<string, 'price' | 'percent' | 'plain' | 'string'> = {
   'pricing.practitioner.seed': 'price',
   'pricing.practitioner.standard': 'price',
   'pricing.practitioner.seed_limit': 'plain',
+  'pricing.practitioner.seed_period_end': 'string',
   'pricing.annual': 'price',
   'commission.practitioner.l1': 'price',
   'commission.practitioner.l1_cend': 'price',
@@ -288,6 +304,7 @@ const ALL_CONFIG_KEYS: string[] = [
   'pricing.practitioner.seed',
   'pricing.practitioner.standard',
   'pricing.practitioner.seed_limit',
+  'pricing.practitioner.seed_period_end',
   'pricing.annual',
   'commission.practitioner.l1',
   'commission.practitioner.l1_cend',
@@ -311,6 +328,7 @@ const SEED_DEFAULTS: Record<string, string> = {
   'pricing.practitioner.seed': '131400',
   'pricing.practitioner.standard': '198600',
   'pricing.practitioner.seed_limit': '300',
+  'pricing.practitioner.seed_period_end': '2027-12-31T23:59:59',
   'pricing.annual': '13100',
   'commission.practitioner.l1': '50000',
   'commission.practitioner.l1_cend': '20000',
@@ -329,7 +347,7 @@ const SEED_DEFAULTS: Record<string, string> = {
 
 /* ──────────── Card key groups ──────────── */
 
-const pricingKeys = ['pricing.practitioner.seed', 'pricing.practitioner.standard', 'pricing.practitioner.seed_limit', 'pricing.annual']
+const pricingKeys = ['pricing.practitioner.seed', 'pricing.practitioner.standard', 'pricing.practitioner.seed_limit', 'pricing.practitioner.seed_period_end', 'pricing.annual']
 const bCommissionKeys = ['commission.practitioner.l1', 'commission.practitioner.l1_cend', 'commission.practitioner.l2']
 const cCommissionKeys = ['commission.annual.direct_rate', 'commission.annual.upstream_rate']
 const planKeys = ['commerce.category.practitioner_plan.commission_rate', 'commission.practitioner_plan.referral_rate', 'commission.practitioner_plan.upstream_rate', 'plan_request.min_price', 'plan_request.max_price', 'plan_request.max_negotiation_rounds']
@@ -340,6 +358,7 @@ const FIELD_LABELS: Record<string, string> = {
   'pricing.practitioner.seed': '能量师种子价',
   'pricing.practitioner.standard': '能量师标准价',
   'pricing.practitioner.seed_limit': '种子价名额上限',
+  'pricing.practitioner.seed_period_end': '种子价有效期',
   'pricing.annual': 'C端年费',
   'commission.practitioner.l1': 'L1 佣金—能量师推荐',
   'commission.practitioner.l1_cend': 'L1 佣金—C端推荐',
@@ -364,7 +383,7 @@ const seedUsedCount = ref(0)
 const storageValues = reactive<Record<string, string>>({})
 
 /** Stores current display-unit editable values */
-const configValues = reactive<Record<string, number>>({})
+const configValues = reactive<Record<string, number | string>>({})
 
 /** Initialise with seed defaults */
 Object.entries(SEED_DEFAULTS).forEach(([key, value]) => {
@@ -375,17 +394,20 @@ Object.entries(SEED_DEFAULTS).forEach(([key, value]) => {
 
 /* ──────────── Unit conversion ──────────── */
 
-function storageToDisplay(key: string, raw: string): number {
+function storageToDisplay(key: string, raw: string): number | string {
   const vt = VALUE_TYPES[key]
+  if (vt === 'string') return raw
   const num = Number(raw)
   if (vt === 'price' || vt === 'percent') return num / 100
   return num
 }
 
-function displayToStorage(key: string, display: number): number {
+function displayToStorage(key: string, display: number | string): number | string {
   const vt = VALUE_TYPES[key]
-  if (vt === 'price' || vt === 'percent') return Math.round(display * 100)
-  return Math.round(display)
+  if (vt === 'string') return display
+  const num = display as number
+  if (vt === 'price' || vt === 'percent') return Math.round(num * 100)
+  return Math.round(num)
 }
 
 /* ──────────── Formatting helpers ──────────── */
@@ -418,6 +440,18 @@ function resetTooltipContent(keys: string[]): string {
 const seedPrice = computed(() => configValues['pricing.practitioner.seed'] || 0)
 const standardPrice = computed(() => configValues['pricing.practitioner.standard'] || 0)
 
+/** 种子价有效期剩余天数 */
+const seedPeriodHint = computed(() => {
+  const val = configValues['pricing.practitioner.seed_period_end']
+  if (!val) return '未设置有效期'
+  const end = new Date(val).getTime()
+  const now = Date.now()
+  const diff = end - now
+  if (diff <= 0) return '🔴 已过期'
+  const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
+  return '🟢 有效期剩余 ' + days + ' 天'
+})
+
 /** Card 2 — B端佣金实时百分比 */
 const l1SeedPercent = computed(() => {
   const s = seedPrice.value

+ 14 - 3
admin/src/views/users/index.vue

@@ -7,7 +7,7 @@
           <el-input v-model="search" placeholder="搜索用户..." prefix-icon="Search" style="width: 250px" clearable />
         </div>
       </template>
-      <el-table :data="filteredUsers" v-loading="loading" stripe>
+      <el-table :data="pagedUsers" v-loading="loading" stripe>
         <el-table-column prop="id" label="ID" width="60" />
         <el-table-column label="头像" width="60">
           <template #default="{ row }">
@@ -37,21 +37,27 @@
         </el-table-column>
       </el-table>
       <div class="pagination-wrap">
-        <el-pagination background layout="total, prev, pager, next" :total="users.length" :page-size="20" />
+        <el-pagination background layout="total, sizes, prev, pager, next" :total="filteredUsers.length" v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[10, 20, 50, 100]" />
       </div>
     </el-card>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, computed } from 'vue'
+import { ref, computed, watch } from 'vue'
 import { ElMessage } from 'element-plus'
 import request from '@/utils/request'
 
 const loading = ref(false)
 const search = ref('')
+const currentPage = ref(1)
+const pageSize = ref(20)
 const users = ref<any[]>([])
 
+watch(search, () => {
+  currentPage.value = 1
+})
+
 const filteredUsers = computed(() => {
   if (!search.value) return users.value
   return users.value.filter((u: any) =>
@@ -59,6 +65,11 @@ const filteredUsers = computed(() => {
   )
 })
 
+const pagedUsers = computed(() => {
+  const start = (currentPage.value - 1) * pageSize.value
+  return filteredUsers.value.slice(start, start + pageSize.value)
+})
+
 async function loadUsers() {
   loading.value = true
   try {

+ 22 - 5
admin/src/views/withdraw/index.vue

@@ -7,7 +7,7 @@
           <el-button size="small" @click="loadWithdraws">刷新</el-button>
         </div>
       </template>
-      <el-table :data="withdraws" v-loading="loading" stripe>
+      <el-table :data="pagedWithdraws" v-loading="loading" stripe>
         <el-table-column prop="id" label="ID" width="80" />
         <el-table-column prop="userId" label="用户ID" width="100" />
         <el-table-column label="金额" width="120">
@@ -42,17 +42,32 @@
           </template>
         </el-table-column>
       </el-table>
+      <el-pagination
+        v-model:current-page="currentPage"
+        v-model:page-size="pageSize"
+        :total="withdraws.length"
+        :page-sizes="[10, 20, 50, 100]"
+        layout="total, sizes, prev, pager, next"
+        background
+        style="margin-top: 16px; justify-content: center;"
+      />
     </el-card>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref } from 'vue'
-import { ElMessage } from 'element-plus'
+import { ref, computed } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
 import request from '@/utils/request'
 
 const loading = ref(false)
 const withdraws = ref<any[]>([])
+const currentPage = ref(1)
+const pageSize = ref(20)
+const pagedWithdraws = computed(() => {
+  const start = (currentPage.value - 1) * pageSize.value
+  return withdraws.value.slice(start, start + pageSize.value)
+})
 
 function statusType(status: string) {
   const map: Record<string, string> = {
@@ -83,21 +98,23 @@ async function loadWithdraws() {
 
 async function handleApprove(row: any) {
   try {
+    await ElMessageBox.confirm(`确定通过提现申请 #${row.id},金额 ¥${(row.amount / 100).toFixed(2)}?`, '确认通过', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
     await request.post('/admin/withdraw/approve', { id: row.id })
     ElMessage.success('提现已通过')
     await loadWithdraws()
   } catch {
-    ElMessage.error('操作失败')
+    // cancelled or error - do nothing
   }
 }
 
 async function handleReject(row: any) {
   try {
+    await ElMessageBox.confirm(`确定拒绝提现申请 #${row.id},金额 ¥${(row.amount / 100).toFixed(2)}?`, '确认拒绝', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
     await request.post('/admin/withdraw/reject', { id: row.id })
     ElMessage.success('已拒绝该提现')
     await loadWithdraws()
   } catch {
-    ElMessage.error('操作失败')
+    // cancelled or error - do nothing
   }
 }