소스 검색

feat: 管理后台前端页面+API封装

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
liaoxg 3 달 전
부모
커밋
6c839bcf6a
3개의 변경된 파일1208개의 추가작업 그리고 1개의 파일을 삭제
  1. 2 1
      client/pages.json
  2. 1145 0
      client/pages/admin/index.vue
  3. 61 0
      client/utils/api.js

+ 2 - 1
client/pages.json

@@ -12,7 +12,8 @@
     {"path": "pages/withdraw/index", "style": {"navigationBarTitleText": "提现"}},
     {"path": "pages/plan/apply", "style": {"navigationBarTitleText": "申请人工方案"}},
     {"path": "pages/plan/workbench", "style": {"navigationBarTitleText": "能量师工作台", "enablePullDownRefresh": true, "backgroundColor": "#0c0a1a"}},
-    {"path": "pages/plan/monitor", "style": {"navigationBarTitleText": "咨询监看", "enablePullDownRefresh": true, "backgroundColor": "#0c0a1a"}}
+    {"path": "pages/plan/monitor", "style": {"navigationBarTitleText": "咨询监看", "enablePullDownRefresh": true, "backgroundColor": "#0c0a1a"}},
+    {"path": "pages/admin/index", "style": {"navigationBarTitleText": "管理后台", "backgroundColor": "#0c0a1a", "enablePullDownRefresh": true}}
   ],
   "globalStyle": {
     "navigationBarTextStyle": "black",

+ 1145 - 0
client/pages/admin/index.vue

@@ -0,0 +1,1145 @@
+<template>
+  <view class="page-admin">
+    <!-- ===== Login Screen ===== -->
+    <view v-if="!authenticated" class="login-screen">
+      <view class="login-card">
+        <text class="login-title">管理后台</text>
+        <text class="login-subtitle">数字能量学系统</text>
+        <view class="login-form">
+          <view class="login-field">
+            <text class="field-label">用户名</text>
+            <input
+              class="field-input"
+              v-model="loginForm.username"
+              placeholder="请输入管理员账号"
+              placeholder-class="placeholder-style"
+              @confirm="handleLogin"
+            />
+          </view>
+          <view class="login-field">
+            <text class="field-label">密码</text>
+            <input
+              class="field-input"
+              type="password"
+              v-model="loginForm.password"
+              placeholder="请输入密码"
+              placeholder-class="placeholder-style"
+              @confirm="handleLogin"
+            />
+          </view>
+          <view class="login-error" v-if="loginError">
+            <text class="login-error-text">{{ loginError }}</text>
+          </view>
+          <button
+            class="login-btn"
+            :disabled="loggingIn"
+            @click="handleLogin"
+          >
+            <text v-if="loggingIn">登录中...</text>
+            <text v-else>登 录</text>
+          </button>
+        </view>
+      </view>
+    </view>
+
+    <!-- ===== Main Admin Dashboard ===== -->
+    <view v-else class="dashboard">
+      <!-- Refresh indicator -->
+      <view v-if="loading" class="loading-state">
+        <text class="loading-text">加载中...</text>
+      </view>
+
+      <template v-else>
+        <!-- ===== Stats Grid ===== -->
+        <view class="stats-grid">
+          <view class="stat-card">
+            <text class="stat-value accent-blue">{{ stats.totalUsers }}</text>
+            <text class="stat-label">总用户数</text>
+          </view>
+          <view class="stat-card">
+            <text class="stat-value accent-green">{{ stats.vipUsers }}</text>
+            <text class="stat-label">VIP 用户</text>
+          </view>
+          <view class="stat-card">
+            <text class="stat-value accent-amber">{{ stats.totalOrders }}</text>
+            <text class="stat-label">总订单</text>
+          </view>
+          <view class="stat-card">
+            <text class="stat-value accent-rose">{{ formatMoney(stats.totalRevenue) }}</text>
+            <text class="stat-label">总收入 (¥)</text>
+          </view>
+          <view class="stat-card">
+            <text class="stat-value accent-blue">{{ stats.totalCharts }}</text>
+            <text class="stat-label">命盘总数</text>
+          </view>
+          <view class="stat-card">
+            <text class="stat-value accent-green">{{ stats.totalCommissions }}</text>
+            <text class="stat-label">佣金笔数</text>
+          </view>
+        </view>
+
+        <!-- ===== Recent Orders Preview ===== -->
+        <view class="section-card" v-if="stats.recentOrders && stats.recentOrders.length > 0">
+          <view class="section-header">
+            <text class="section-title">最近订单</text>
+          </view>
+          <view
+            v-for="(order, idx) in stats.recentOrders.slice(0, 5)"
+            :key="idx"
+            class="mini-row"
+          >
+            <view class="mini-left">
+              <text class="mini-label">#{{ order.id }}</text>
+              <text class="mini-desc">{{ order.productType || '--' }}</text>
+            </view>
+            <view class="mini-right">
+              <text class="mini-value accent-amber">¥{{ formatMoney(order.totalFee) }}</text>
+              <text class="mini-status" :class="'status-' + order.status">
+                {{ orderStatusLabel(order.status) }}
+              </text>
+            </view>
+          </view>
+        </view>
+
+        <!-- ===== Tab Navigation ===== -->
+        <view class="tab-bar">
+          <view
+            v-for="tab in tabs"
+            :key="tab.key"
+            class="tab-item"
+            :class="{ 'tab-active': currentTab === tab.key }"
+            @click="currentTab = tab.key"
+          >
+            <text class="tab-text">{{ tab.label }}</text>
+          </view>
+        </view>
+
+        <!-- ===== Users Tab ===== -->
+        <view v-show="currentTab === 'users'" class="tab-content">
+          <view v-if="users.length === 0" class="empty-state">
+            <text class="empty-text">暂无用户数据</text>
+          </view>
+          <view v-for="(user, idx) in users" :key="user.id || idx" class="data-card">
+            <view class="data-row">
+              <view class="data-left">
+                <view class="data-info">
+                  <text class="data-primary">
+                    {{ user.nickname || '微信用户' }}
+                    <text v-if="user.isAdmin" class="badge-admin">管理员</text>
+                  </text>
+                  <text class="data-meta">ID: {{ user.id }} | 注册: {{ formatDate(user.createdAt) }}</text>
+                </view>
+              </view>
+              <view class="data-right">
+                <text
+                  class="tag-vip"
+                  :class="{ 'tag-vip-active': isVipActive(user) }"
+                >
+                  {{ isVipActive(user) ? 'VIP' : '普通' }}
+                </text>
+              </view>
+            </view>
+            <view class="action-row">
+              <button
+                v-if="!isVipActive(user)"
+                class="btn btn-sm btn-green"
+                :disabled="actionLoading.userId === user.id"
+                @click="handleUpgrade(user.id)"
+              >
+                {{ actionLoading.userId === user.id && actionLoading.type === 'upgrade' ? '...' : '升级VIP' }}
+              </button>
+              <button
+                v-if="isVipActive(user)"
+                class="btn btn-sm btn-red-outline"
+                :disabled="actionLoading.userId === user.id"
+                @click="handleDowngrade(user.id)"
+              >
+                {{ actionLoading.userId === user.id && actionLoading.type === 'downgrade' ? '...' : '取消VIP' }}
+              </button>
+              <button
+                class="btn btn-sm"
+                :class="user.isAdmin ? 'btn-amber-outline' : 'btn-blue-outline'"
+                :disabled="actionLoading.userId === user.id"
+                @click="handleToggleAdmin(user)"
+              >
+                {{
+                  actionLoading.userId === user.id && actionLoading.type === 'set-admin'
+                    ? '...'
+                    : (user.isAdmin ? '取消管理' : '设为管理')
+                }}
+              </button>
+            </view>
+          </view>
+        </view>
+
+        <!-- ===== Orders Tab ===== -->
+        <view v-show="currentTab === 'orders'" class="tab-content">
+          <view v-if="orders.length === 0" class="empty-state">
+            <text class="empty-text">暂无订单数据</text>
+          </view>
+          <view v-for="(order, idx) in orders" :key="order.id || idx" class="data-card">
+            <view class="data-row">
+              <view class="data-left">
+                <view class="data-info">
+                  <text class="data-primary">订单 #{{ order.id }}</text>
+                  <text class="data-meta">
+                    用户ID: {{ order.userId }} | {{ order.productType || '--' }}
+                  </text>
+                </view>
+              </view>
+              <view class="data-right">
+                <text class="data-price">¥{{ formatMoney(order.totalFee) }}</text>
+                <text class="tag-status" :class="'tag-status-' + order.status">
+                  {{ orderStatusLabel(order.status) }}
+                </text>
+              </view>
+            </view>
+            <view class="data-extras" v-if="order.paidAt || order.createdAt">
+              <text class="data-meta" v-if="order.paidAt">支付时间: {{ formatDate(order.paidAt) }}</text>
+              <text class="data-meta" v-else>创建时间: {{ formatDate(order.createdAt) }}</text>
+            </view>
+          </view>
+        </view>
+
+        <!-- ===== Commissions Tab ===== -->
+        <view v-show="currentTab === 'commissions'" class="tab-content">
+          <view v-if="commissions.length === 0" class="empty-state">
+            <text class="empty-text">暂无佣金数据</text>
+          </view>
+          <view v-for="(comm, idx) in commissions" :key="comm.id || idx" class="data-card">
+            <view class="data-row">
+              <view class="data-left">
+                <view class="data-info">
+                  <text class="data-primary">用户 #{{ comm.userId }}</text>
+                  <text class="data-meta">
+                    级别: {{ comm.level === 1 ? '一级推广' : '二级推广' }}
+                    <text v-if="comm.remark"> | {{ comm.remark }}</text>
+                  </text>
+                </view>
+              </view>
+              <view class="data-right">
+                <text class="data-price accent-green">+¥{{ formatMoney(comm.amount) }}</text>
+                <text class="tag-status" :class="'tag-status-' + comm.status">
+                  {{ commissionStatusLabel(comm.status) }}
+                </text>
+              </view>
+            </view>
+            <view class="data-extras">
+              <text class="data-meta">{{ formatDate(comm.createdAt) }}</text>
+            </view>
+          </view>
+        </view>
+
+        <!-- ===== Withdraws Tab ===== -->
+        <view v-show="currentTab === 'withdraws'" class="tab-content">
+          <view v-if="withdraws.length === 0" class="empty-state">
+            <text class="empty-text">暂无提现申请</text>
+          </view>
+          <view v-for="(wd, idx) in withdraws" :key="wd.id || idx" class="data-card">
+            <view class="data-row">
+              <view class="data-left">
+                <view class="data-info">
+                  <text class="data-primary">提现 #{{ wd.id }}</text>
+                  <text class="data-meta">用户ID: {{ wd.userId }}</text>
+                </view>
+              </view>
+              <view class="data-right">
+                <text class="data-price">¥{{ formatMoney(wd.amount) }}</text>
+                <text class="tag-status" :class="'tag-status-' + wd.status">
+                  {{ withdrawStatusLabel(wd.status) }}
+                </text>
+              </view>
+            </view>
+            <view class="data-extras">
+              <text class="data-meta">{{ formatDate(wd.createdAt) }}</text>
+            </view>
+            <view class="action-row" v-if="wd.status === 'pending'">
+              <button
+                class="btn btn-sm btn-green"
+                :disabled="actionLoading.withdrawId === wd.id"
+                @click="handleApproveWithdraw(wd.id)"
+              >
+                {{ actionLoading.withdrawId === wd.id && actionLoading.type === 'approve' ? '...' : '批准' }}
+              </button>
+              <button
+                class="btn btn-sm btn-red-outline"
+                :disabled="actionLoading.withdrawId === wd.id"
+                @click="handleRejectWithdraw(wd.id)"
+              >
+                {{ actionLoading.withdrawId === wd.id && actionLoading.type === 'reject' ? '...' : '拒绝' }}
+              </button>
+            </view>
+          </view>
+        </view>
+
+        <!-- ===== Config Tab ===== -->
+        <view v-show="currentTab === 'config'" class="tab-content">
+          <view v-if="configs.length === 0" class="empty-state">
+            <text class="empty-text">暂无系统配置</text>
+          </view>
+          <view v-for="(cfg, idx) in configs" :key="cfg.id || idx" class="data-card">
+            <view class="config-row">
+              <view class="config-header">
+                <text class="data-primary">{{ cfg.configKey }}</text>
+                <button
+                  class="btn btn-xxs btn-amber-outline"
+                  :disabled="configResetting === cfg.configKey"
+                  @click="handleResetConfig(cfg.configKey)"
+                >
+                  {{ configResetting === cfg.configKey ? '...' : '重置' }}
+                </button>
+              </view>
+              <view class="config-value-row">
+                <input
+                  class="config-input"
+                  v-model="configEdits[cfg.configKey]"
+                  :placeholder="cfg.configValue || '输入值'"
+                  placeholder-class="placeholder-style"
+                />
+                <button
+                  class="btn btn-xxs btn-green"
+                  :disabled="configSaving === cfg.configKey || configEdits[cfg.configKey] === cfg.configValue"
+                  @click="handleSaveConfig(cfg.configKey)"
+                >
+                  {{ configSaving === cfg.configKey ? '...' : '保存' }}
+                </button>
+              </view>
+              <text class="data-meta config-desc" v-if="cfg.description">{{ cfg.description }}</text>
+              <text class="data-meta config-desc" v-if="cfg.defaultValue">默认值: {{ cfg.defaultValue }}</text>
+            </view>
+          </view>
+        </view>
+      </template>
+    </view>
+
+    <!-- ===== Admin logout hint ===== -->
+    <view v-if="authenticated" class="logout-bar" @click="handleLogout">
+      <text class="logout-text">退出登录</text>
+    </view>
+  </view>
+</template>
+
+<script setup>
+import { ref, reactive, computed, onMounted } from 'vue'
+import { onPullDownRefresh } from '@dcloudio/uni-app'
+import { adminApi } from '@/utils/api'
+
+// ─── Auth State ───
+const authenticated = ref(!!uni.getStorageSync('adminToken'))
+const loginForm = reactive({ username: '', password: '' })
+const loggingIn = ref(false)
+const loginError = ref('')
+
+// ─── Data State ───
+const loading = ref(true)
+const stats = reactive({
+  totalUsers: 0,
+  vipUsers: 0,
+  totalOrders: 0,
+  totalRevenue: 0,
+  totalCharts: 0,
+  totalCommissions: 0,
+  recentOrders: []
+})
+const users = ref([])
+const orders = ref([])
+const commissions = ref([])
+const withdraws = ref([])
+const configs = ref([])
+
+// ─── UI State ───
+const currentTab = ref('users')
+const actionLoading = reactive({ userId: null, withdrawId: null, type: null })
+const configResetting = ref(null)
+const configSaving = ref(null)
+const configEdits = reactive({})
+
+const tabs = [
+  { key: 'users', label: '用户管理' },
+  { key: 'orders', label: '订单管理' },
+  { key: 'commissions', label: '佣金记录' },
+  { key: 'withdraws', label: '提现审核' },
+  { key: 'config', label: '系统配置' }
+]
+
+// ─── Helpers ───
+function formatMoney(cents) {
+  if (cents == null) return '0.00'
+  return (cents / 100).toFixed(2)
+}
+
+function formatDate(dateStr) {
+  if (!dateStr) return '--'
+  const d = new Date(dateStr)
+  if (isNaN(d.getTime())) return '--'
+  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+}
+
+function isVipActive(user) {
+  if (!user.vipEndTime) return false
+  return new Date(user.vipEndTime) > new Date()
+}
+
+function orderStatusLabel(status) {
+  const map = { paid: '已支付', unpaid: '未支付', cancelled: '已取消', refunded: '已退款', pending: '处理中' }
+  return map[status] || status || '未知'
+}
+
+function commissionStatusLabel(status) {
+  const map = { settled: '已结算', pending: '处理中', withdrawn: '已提现', available: '可提现' }
+  return map[status] || status || '未知'
+}
+
+function withdrawStatusLabel(status) {
+  const map = { pending: '待审核', approved: '已批准', rejected: '已拒绝', completed: '已完成' }
+  return map[status] || status || '未知'
+}
+
+// ─── Auth ───
+async function handleLogin() {
+  if (!loginForm.username || !loginForm.password) {
+    loginError.value = '请输入用户名和密码'
+    return
+  }
+  loginError.value = ''
+  loggingIn.value = true
+  try {
+    const result = await adminApi.login(loginForm.username, loginForm.password)
+    uni.setStorageSync('adminToken', result.token)
+    authenticated.value = true
+    await fetchAllData()
+  } catch (e) {
+    loginError.value = e.message || '登录失败,请检查账号密码'
+  } finally {
+    loggingIn.value = false
+  }
+}
+
+function handleLogout() {
+  uni.showModal({
+    title: '退出登录',
+    content: '确定要退出管理后台吗?',
+    success: (res) => {
+      if (res.confirm) {
+        uni.removeStorageSync('adminToken')
+        authenticated.value = false
+        loginForm.username = ''
+        loginForm.password = ''
+      }
+    }
+  })
+}
+
+// ─── Data Fetching ───
+async function fetchAllData() {
+  loading.value = true
+  try {
+    const [
+      statsData,
+      usersData,
+      ordersData,
+      commissionsData,
+      withdrawsData,
+      configsData
+    ] = await Promise.all([
+      adminApi.stats(),
+      adminApi.users(),
+      adminApi.orders(),
+      adminApi.commissions(),
+      adminApi.withdraws(),
+      adminApi.configList()
+    ])
+    if (statsData) {
+      stats.totalUsers = statsData.totalUsers || 0
+      stats.vipUsers = statsData.vipUsers || 0
+      stats.totalOrders = statsData.totalOrders || 0
+      stats.totalRevenue = statsData.totalRevenue || 0
+      stats.totalCharts = statsData.totalCharts || 0
+      stats.totalCommissions = statsData.totalCommissions || 0
+      stats.recentOrders = statsData.recentOrders || []
+    }
+    users.value = usersData || []
+    orders.value = ordersData || []
+    commissions.value = commissionsData || []
+    withdraws.value = withdrawsData || []
+    configs.value = configsData || []
+    // Init config edits
+    if (configsData) {
+      configsData.forEach(cfg => {
+        configEdits[cfg.configKey] = cfg.configValue || ''
+      })
+    }
+  } catch (e) {
+    if (e.code === 1001) {
+      // Token expired — redirect to login
+      uni.removeStorageSync('adminToken')
+      authenticated.value = false
+    }
+    uni.showToast({ title: e.message || '数据加载失败', icon: 'none' })
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(async () => {
+  if (authenticated.value) {
+    await fetchAllData()
+  } else {
+    loading.value = false
+  }
+})
+
+onPullDownRefresh(async () => {
+  if (authenticated.value) {
+    await fetchAllData()
+  }
+  uni.stopPullDownRefresh()
+})
+
+// ─── User Actions ───
+async function handleUpgrade(userId) {
+  actionLoading.userId = userId
+  actionLoading.type = 'upgrade'
+  try {
+    await adminApi.upgradeUser(userId)
+    uni.showToast({ title: '已升级为VIP', icon: 'success' })
+    await fetchAllData()
+  } catch (e) {
+    uni.showToast({ title: e.message || '操作失败', icon: 'none' })
+  } finally {
+    actionLoading.userId = null
+    actionLoading.type = null
+  }
+}
+
+async function handleDowngrade(userId) {
+  uni.showModal({
+    title: '取消VIP',
+    content: '确定要取消该用户的VIP资格吗?',
+    success: async (res) => {
+      if (!res.confirm) return
+      actionLoading.userId = userId
+      actionLoading.type = 'downgrade'
+      try {
+        await adminApi.downgradeUser(userId)
+        uni.showToast({ title: '已取消VIP', icon: 'success' })
+        await fetchAllData()
+      } catch (e) {
+        uni.showToast({ title: e.message || '操作失败', icon: 'none' })
+      } finally {
+        actionLoading.userId = null
+        actionLoading.type = null
+      }
+    }
+  })
+}
+
+async function handleToggleAdmin(user) {
+  const newIsAdmin = !user.isAdmin
+  actionLoading.userId = user.id
+  actionLoading.type = 'set-admin'
+  try {
+    await adminApi.setAdmin(user.id, newIsAdmin)
+    uni.showToast({ title: newIsAdmin ? '已设为管理员' : '已取消管理员', icon: 'success' })
+    await fetchAllData()
+  } catch (e) {
+    uni.showToast({ title: e.message || '操作失败', icon: 'none' })
+  } finally {
+    actionLoading.userId = null
+    actionLoading.type = null
+  }
+}
+
+// ─── Withdraw Actions ───
+async function handleApproveWithdraw(id) {
+  actionLoading.withdrawId = id
+  actionLoading.type = 'approve'
+  try {
+    await adminApi.approveWithdraw(id)
+    uni.showToast({ title: '提现已批准', icon: 'success' })
+    await fetchAllData()
+  } catch (e) {
+    uni.showToast({ title: e.message || '操作失败', icon: 'none' })
+  } finally {
+    actionLoading.withdrawId = null
+    actionLoading.type = null
+  }
+}
+
+async function handleRejectWithdraw(id) {
+  uni.showModal({
+    title: '拒绝提现',
+    content: '确定要拒绝该提现申请吗?',
+    success: async (res) => {
+      if (!res.confirm) return
+      actionLoading.withdrawId = id
+      actionLoading.type = 'reject'
+      try {
+        await adminApi.rejectWithdraw(id)
+        uni.showToast({ title: '提现已拒绝', icon: 'success' })
+        await fetchAllData()
+      } catch (e) {
+        uni.showToast({ title: e.message || '操作失败', icon: 'none' })
+      } finally {
+        actionLoading.withdrawId = null
+        actionLoading.type = null
+      }
+    }
+  })
+}
+
+// ─── Config Actions ───
+async function handleSaveConfig(configKey) {
+  const newValue = configEdits[configKey]
+  if (newValue == null) return
+  configSaving.value = configKey
+  try {
+    await adminApi.configUpdate({ key: configKey, value: newValue })
+    uni.showToast({ title: '配置已更新', icon: 'success' })
+    await fetchAllData()
+  } catch (e) {
+    uni.showToast({ title: e.message || '保存失败', icon: 'none' })
+  } finally {
+    configSaving.value = null
+  }
+}
+
+async function handleResetConfig(configKey) {
+  uni.showModal({
+    title: '重置配置',
+    content: `确定要将 "${configKey}" 重置为默认值吗?`,
+    success: async (res) => {
+      if (!res.confirm) return
+      configResetting.value = configKey
+      try {
+        await adminApi.configReset(configKey)
+        uni.showToast({ title: '配置已重置', icon: 'success' })
+        await fetchAllData()
+      } catch (e) {
+        uni.showToast({ title: e.message || '重置失败', icon: 'none' })
+      } finally {
+        configResetting.value = null
+      }
+    }
+  })
+}
+</script>
+
+<style scoped lang="scss">
+.page-admin {
+  min-height: 100vh;
+  background: linear-gradient(180deg, #0c0a1a 0%, #1a1232 100%);
+  padding: 16px;
+  padding-bottom: 40px;
+}
+
+// ─── Placeholder ───
+.placeholder-style {
+  color: rgba(255, 255, 255, 0.25);
+}
+
+// ─── Login Screen ───
+.login-screen {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 90vh;
+  padding: 20px;
+}
+
+.login-card {
+  width: 100%;
+  max-width: 340px;
+  background: rgba(255, 255, 255, 0.03);
+  border: 1px solid rgba(255, 255, 255, 0.06);
+  border-radius: 20px;
+  padding: 32px 24px;
+}
+
+.login-title {
+  display: block;
+  font-size: 26px;
+  font-weight: 700;
+  color: rgba(255, 255, 255, 0.95);
+  text-align: center;
+  margin-bottom: 6px;
+}
+
+.login-subtitle {
+  display: block;
+  font-size: 13px;
+  color: rgba(255, 255, 255, 0.4);
+  text-align: center;
+  margin-bottom: 32px;
+}
+
+.login-form {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.login-field {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.field-label {
+  font-size: 13px;
+  font-weight: 500;
+  color: rgba(255, 255, 255, 0.6);
+}
+
+.field-input {
+  height: 44px;
+  padding: 0 14px;
+  background: rgba(255, 255, 255, 0.04);
+  border: 1px solid rgba(255, 255, 255, 0.08);
+  border-radius: 10px;
+  font-size: 15px;
+  color: rgba(255, 255, 255, 0.9);
+  box-sizing: border-box;
+}
+
+.login-error {
+  padding: 8px 12px;
+  background: rgba(239, 68, 68, 0.1);
+  border-radius: 8px;
+}
+
+.login-error-text {
+  font-size: 13px;
+  color: #ef4444;
+}
+
+.login-btn {
+  height: 46px;
+  margin-top: 8px;
+  background: linear-gradient(135deg, #3b82f6, #2563eb);
+  border: none;
+  border-radius: 12px;
+  font-size: 16px;
+  font-weight: 600;
+  color: #fff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  line-height: 1;
+}
+
+.login-btn[disabled] {
+  opacity: 0.5;
+}
+
+// ─── Loading ───
+.loading-state {
+  padding: 60px 0;
+  text-align: center;
+}
+
+.loading-text {
+  font-size: 14px;
+  color: rgba(255, 255, 255, 0.4);
+}
+
+// ─── Stats Grid ───
+.stats-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr 1fr;
+  gap: 8px;
+  margin-bottom: 16px;
+}
+
+.stat-card {
+  background: rgba(255, 255, 255, 0.02);
+  border: 1px solid rgba(255, 255, 255, 0.04);
+  border-radius: 14px;
+  padding: 14px 10px;
+  text-align: center;
+}
+
+.stat-value {
+  display: block;
+  font-size: 22px;
+  font-weight: 700;
+  margin-bottom: 4px;
+}
+
+.stat-label {
+  display: block;
+  font-size: 11px;
+  color: rgba(255, 255, 255, 0.45);
+}
+
+.accent-blue { color: #3b82f6; }
+.accent-green { color: #10b981; }
+.accent-amber { color: #f59e0b; }
+.accent-rose { color: #f472b6; }
+
+// ─── Section Card ───
+.section-card {
+  background: rgba(255, 255, 255, 0.02);
+  border: 1px solid rgba(255, 255, 255, 0.04);
+  border-radius: 16px;
+  padding: 14px;
+  margin-bottom: 16px;
+}
+
+.section-header {
+  margin-bottom: 10px;
+}
+
+.section-title {
+  font-size: 15px;
+  font-weight: 700;
+  color: rgba(255, 255, 255, 0.85);
+}
+
+.mini-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 8px 0;
+  border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+}
+
+.mini-row:last-child {
+  border-bottom: none;
+}
+
+.mini-left {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.mini-label {
+  font-size: 13px;
+  font-weight: 500;
+  color: rgba(255, 255, 255, 0.8);
+}
+
+.mini-desc {
+  font-size: 11px;
+  color: rgba(255, 255, 255, 0.4);
+}
+
+.mini-right {
+  text-align: right;
+}
+
+.mini-value {
+  display: block;
+  font-size: 14px;
+  font-weight: 600;
+}
+
+.mini-status {
+  font-size: 10px;
+  padding: 2px 8px;
+  border-radius: 4px;
+  display: inline-block;
+  margin-top: 2px;
+}
+
+// ─── Tab Bar ───
+.tab-bar {
+  display: flex;
+  gap: 6px;
+  margin-bottom: 14px;
+  overflow-x: auto;
+  white-space: nowrap;
+  -webkit-overflow-scrolling: touch;
+  padding-bottom: 4px;
+}
+
+.tab-item {
+  flex-shrink: 0;
+  padding: 8px 16px;
+  border-radius: 10px;
+  background: rgba(255, 255, 255, 0.02);
+  border: 1px solid rgba(255, 255, 255, 0.04);
+}
+
+.tab-active {
+  background: rgba(59, 130, 246, 0.12);
+  border-color: rgba(59, 130, 246, 0.25);
+}
+
+.tab-text {
+  font-size: 13px;
+  font-weight: 500;
+  color: rgba(255, 255, 255, 0.5);
+}
+
+.tab-active .tab-text {
+  color: #3b82f6;
+}
+
+// ─── Tab Content ───
+.tab-content {
+  margin-bottom: 16px;
+}
+
+// ─── Data Card ───
+.data-card {
+  background: rgba(255, 255, 255, 0.02);
+  border: 1px solid rgba(255, 255, 255, 0.04);
+  border-radius: 14px;
+  padding: 14px;
+  margin-bottom: 10px;
+}
+
+.data-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+}
+
+.data-left {
+  flex: 1;
+  min-width: 0;
+}
+
+.data-info {
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+}
+
+.data-primary {
+  font-size: 14px;
+  font-weight: 600;
+  color: rgba(255, 255, 255, 0.85);
+}
+
+.data-meta {
+  font-size: 11px;
+  color: rgba(255, 255, 255, 0.4);
+}
+
+.data-right {
+  text-align: right;
+  flex-shrink: 0;
+  margin-left: 12px;
+}
+
+.data-price {
+  display: block;
+  font-size: 15px;
+  font-weight: 700;
+  color: rgba(255, 255, 255, 0.85);
+  margin-bottom: 3px;
+}
+
+.data-extras {
+  margin-top: 6px;
+  padding-top: 6px;
+  border-top: 1px solid rgba(255, 255, 255, 0.04);
+}
+
+// ─── Tags ───
+.tag-vip {
+  font-size: 10px;
+  font-weight: 600;
+  padding: 2px 10px;
+  border-radius: 999px;
+  background: rgba(107, 114, 128, 0.2);
+  color: rgba(255, 255, 255, 0.4);
+}
+
+.tag-vip-active {
+  background: rgba(16, 185, 129, 0.15);
+  color: #10b981;
+}
+
+.tag-status {
+  font-size: 10px;
+  font-weight: 500;
+  padding: 2px 8px;
+  border-radius: 4px;
+  display: inline-block;
+}
+
+.tag-status-paid,
+.tag-status-settled,
+.tag-status-approved,
+.tag-status-completed {
+  background: rgba(16, 185, 129, 0.12);
+  color: #10b981;
+}
+
+.tag-status-unpaid,
+.tag-status-pending {
+  background: rgba(245, 158, 11, 0.12);
+  color: #f59e0b;
+}
+
+.tag-status-cancelled,
+.tag-status-rejected {
+  background: rgba(239, 68, 68, 0.12);
+  color: #ef4444;
+}
+
+.tag-status-refunded {
+  background: rgba(107, 114, 128, 0.12);
+  color: #9ca3af;
+}
+
+.tag-status-available {
+  background: rgba(59, 130, 246, 0.12);
+  color: #3b82f6;
+}
+
+.tag-status-withdrawn {
+  background: rgba(107, 114, 128, 0.12);
+  color: #9ca3af;
+}
+
+.badge-admin {
+  font-size: 9px;
+  font-weight: 600;
+  background: rgba(59, 130, 246, 0.15);
+  color: #3b82f6;
+  padding: 1px 6px;
+  border-radius: 4px;
+  margin-left: 6px;
+  vertical-align: middle;
+}
+
+// ─── Action Buttons ───
+.action-row {
+  display: flex;
+  gap: 8px;
+  margin-top: 10px;
+  padding-top: 10px;
+  border-top: 1px solid rgba(255, 255, 255, 0.04);
+}
+
+.btn {
+  border: none;
+  border-radius: 8px;
+  font-size: 12px;
+  font-weight: 600;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  line-height: 1;
+  height: auto;
+  padding: 8px 14px;
+}
+
+.btn-sm {
+  font-size: 11px;
+  padding: 6px 12px;
+  height: 30px;
+}
+
+.btn-xxs {
+  font-size: 10px;
+  padding: 4px 10px;
+  height: 26px;
+}
+
+.btn-green {
+  background: #10b981;
+  color: #fff;
+}
+
+.btn-green[disabled] {
+  opacity: 0.5;
+}
+
+.btn-red-outline {
+  background: transparent;
+  border: 1px solid #ef4444;
+  color: #ef4444;
+}
+
+.btn-red-outline[disabled] {
+  opacity: 0.5;
+}
+
+.btn-blue-outline {
+  background: transparent;
+  border: 1px solid rgba(59, 130, 246, 0.4);
+  color: #3b82f6;
+}
+
+.btn-blue-outline[disabled] {
+  opacity: 0.5;
+}
+
+.btn-amber-outline {
+  background: transparent;
+  border: 1px solid rgba(245, 158, 11, 0.4);
+  color: #f59e0b;
+}
+
+.btn-amber-outline[disabled] {
+  opacity: 0.5;
+}
+
+// ─── Config ───
+.config-row {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.config-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.config-value-row {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+}
+
+.config-input {
+  flex: 1;
+  height: 34px;
+  padding: 0 10px;
+  background: rgba(255, 255, 255, 0.04);
+  border: 1px solid rgba(255, 255, 255, 0.08);
+  border-radius: 8px;
+  font-size: 13px;
+  color: rgba(255, 255, 255, 0.9);
+  box-sizing: border-box;
+  min-width: 0;
+}
+
+.config-desc {
+  font-size: 10px;
+}
+
+// ─── Empty State ───
+.empty-state {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 60px 20px;
+}
+
+.empty-text {
+  font-size: 13px;
+  color: rgba(255, 255, 255, 0.35);
+}
+
+// ─── Logout ───
+.logout-bar {
+  margin-top: 8px;
+  padding: 12px 0;
+  text-align: center;
+}
+
+.logout-text {
+  font-size: 12px;
+  color: rgba(255, 255, 255, 0.3);
+  text-decoration: underline;
+}
+</style>

+ 61 - 0
client/utils/api.js

@@ -166,6 +166,67 @@ export const interventionApi = {
   send: (data) => requestInstance.post('/intervention/send', data)
 }
 
+/**
+ * Admin-specific request that uses adminToken (stored separately from user token).
+ */
+async function adminRequest(method, url, data) {
+  const token = uni.getStorageSync('adminToken')
+  const header = { 'Content-Type': 'application/json' }
+  if (token) {
+    header.Authorization = `Bearer ${token}`
+  }
+
+  return new Promise((resolve, reject) => {
+    uni.request({
+      url: API_BASE_URL + url,
+      method,
+      data,
+      header,
+      timeout: TIMEOUT,
+      success(res) {
+        const response = res.data
+        if (response.code !== 0) {
+          if (response.code === 1001) {
+            // Token invalid or expired — clear admin token
+            uni.removeStorageSync('adminToken')
+          }
+          const err = new Error(response.message || '请求失败')
+          err.code = response.code
+          reject(err)
+        } else {
+          resolve(response.data)
+        }
+      },
+      fail(err) {
+        uni.showToast({ title: '网络错误', icon: 'none' })
+        reject(err)
+      }
+    })
+  })
+}
+
+const adminRequestInstance = {
+  post: (url, data) => adminRequest('POST', url, data)
+}
+
+export const adminApi = {
+  login: (username, password) => adminRequestInstance.post('/admin/login', { username, password }),
+  stats: () => adminRequestInstance.post('/admin/stats', {}),
+  users: () => adminRequestInstance.post('/admin/users', {}),
+  upgradeUser: (userId) => adminRequestInstance.post('/admin/user/upgrade', { userId }),
+  downgradeUser: (userId) => adminRequestInstance.post('/admin/user/downgrade', { userId }),
+  setAdmin: (userId, isAdmin) => adminRequestInstance.post('/admin/user/set-admin', { userId, isAdmin }),
+  orders: () => adminRequestInstance.post('/admin/orders', {}),
+  commissions: () => adminRequestInstance.post('/admin/commissions', {}),
+  charts: () => adminRequestInstance.post('/admin/charts', {}),
+  withdraws: () => adminRequestInstance.post('/admin/withdraws', {}),
+  approveWithdraw: (id) => adminRequestInstance.post('/admin/withdraw/approve', { id }),
+  rejectWithdraw: (id) => adminRequestInstance.post('/admin/withdraw/reject', { id }),
+  configList: () => adminRequestInstance.post('/admin/config/list', {}),
+  configUpdate: (data) => adminRequestInstance.post('/admin/config/update', data),
+  configReset: (configKey) => adminRequestInstance.post('/admin/config/reset', { configKey })
+}
+
 export const deliveryApi = {
   create: (data) => requestInstance.post('/plan/delivery/create', data),
   list: (planRequestId) => requestInstance.post('/plan/delivery/list', { planRequestId })