531 lines
26 KiB
Vue
531 lines
26 KiB
Vue
<template>
|
||
<view class="page">
|
||
<view class="hero">
|
||
<text class="hero-title">管理后台</text>
|
||
<text class="hero-sub" v-if="!verified">使用管理员账号点击下方按钮验证</text>
|
||
<text class="hero-sub" v-else>欢迎回来,{{ adminName }}</text>
|
||
</view>
|
||
|
||
<!-- 登录 -->
|
||
<view class="login-area" v-if="!verified">
|
||
<button class="btn-verify" @click="doVerify">验证管理员身份</button>
|
||
</view>
|
||
|
||
<!-- 管理后台 -->
|
||
<view class="body" v-if="verified">
|
||
<view class="tabs">
|
||
<text class="tab" :class="{ active: tab === 'overview' }" @click="switchTab('overview')">概览</text>
|
||
<text class="tab" :class="{ active: tab === 'users' }" @click="switchTab('users')">用户</text>
|
||
<text class="tab" :class="{ active: tab === 'interviews' }" @click="switchTab('interviews')">面试</text>
|
||
<text class="tab" :class="{ active: tab === 'orders' }" @click="switchTab('orders')">订单</text>
|
||
<text class="tab" :class="{ active: tab === 'pricing' }" @click="switchTab('pricing')">定价</text>
|
||
<text class="tab" :class="{ active: tab === 'admins' }" @click="switchTab('admins')">管理</text>
|
||
</view>
|
||
|
||
<!-- 概览 -->
|
||
<view v-if="tab === 'overview' && !loading" class="overview">
|
||
<view class="stat-cards">
|
||
<view class="stat-card">
|
||
<text class="stat-num">{{ overview.userCount }}</text>
|
||
<text class="stat-label">总用户</text>
|
||
<text class="stat-sub">今日 +{{ overview.todayUsers }}</text>
|
||
</view>
|
||
<view class="stat-card">
|
||
<text class="stat-num">{{ overview.interviewCount }}</text>
|
||
<text class="stat-label">总面试</text>
|
||
<text class="stat-sub">今日 +{{ overview.todayInterviews }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 用户 -->
|
||
<view v-if="tab === 'users'" class="section">
|
||
<view class="search-bar">
|
||
<input v-model="userKeyword" placeholder="搜索手机号/昵称" class="search-input" @confirm="loadUsers" />
|
||
<button class="search-btn" @click="loadUsers">搜索</button>
|
||
</view>
|
||
<view class="user-list" v-if="!usersLoading">
|
||
<view class="user-row" v-for="u in users" :key="u._id">
|
||
<text class="user-phone">{{ u.phone || '--' }}</text>
|
||
<text class="user-name">{{ u.nickname || '--' }}</text>
|
||
<text class="user-plan" :class="{ vip: u.plan === 'vip' }">{{ u.plan === 'vip' ? '会员' : '免费' }}</text>
|
||
<text class="user-remaining">剩{{ u.remaining || 0 }}次</text>
|
||
<text class="user-vip-btn" v-if="u.plan !== 'vip'" @click="setVip(u._id)">设为会员</text>
|
||
</view>
|
||
<text class="load-more" v-if="usersTotal > users.length" @click="loadMoreUsers">加载更多</text>
|
||
</view>
|
||
<text class="loading-text" v-if="usersLoading">加载中...</text>
|
||
</view>
|
||
|
||
<!-- 面试 -->
|
||
<view v-if="tab === 'interviews'" class="section">
|
||
<view class="iv-list" v-if="!ivLoading">
|
||
<view class="iv-row" v-for="iv in interviews" :key="iv._id">
|
||
<text class="iv-pos">{{ iv.position }}</text>
|
||
<text class="iv-user">{{ iv.userId?.phone || iv.userId?.nickname || '--' }}</text>
|
||
<text class="iv-status" :class="{ done: iv.status === 'completed' }">{{ iv.status === 'completed' ? '已完成' : '进行中' }}</text>
|
||
<text class="iv-questions">{{ iv.questionCount || 0 }}题</text>
|
||
</view>
|
||
</view>
|
||
<text class="loading-text" v-if="ivLoading">加载中...</text>
|
||
</view>
|
||
|
||
|
||
|
||
<!-- 订单 -->
|
||
<view v-if="tab === 'orders'" class="section">
|
||
<view class="tabs in-tab">
|
||
<text class="tab" :class="{ active: orderFilter === '' }" @click="orderFilter='';loadOrders()">全部</text>
|
||
<text class="tab" :class="{ active: orderFilter === 'pending' }" @click="orderFilter='pending';loadOrders()">待支付</text>
|
||
<text class="tab" :class="{ active: orderFilter === 'success' }" @click="orderFilter='success';loadOrders()">已支付</text>
|
||
<text class="tab" :class="{ active: orderFilter === 'refunded' }" @click="orderFilter='refunded';loadOrders()">已退款</text>
|
||
</view>
|
||
<view class="order-list" v-if="!orderLoading">
|
||
<view class="order-row" v-for="o in orders" :key="o._id">
|
||
<view class="order-info">
|
||
<text class="order-id">订单号: {{ o.outTradeNo }}</text>
|
||
<text class="order-user">用户: {{ o.userPhone || o.userId.slice(-6) }}</text>
|
||
</view>
|
||
<view class="order-meta">
|
||
<text class="order-amount">¥{{ (o.amount / 100).toFixed(1) }}</text>
|
||
<view class="order-status" :class="o.status === 'success' ? 'paid' : o.status === 'refunded' ? 'refund' : 'pend'">
|
||
{{ o.status === 'success' ? '已支付' : o.status === 'refunded' ? '已退款' : '待支付' }}
|
||
</view>
|
||
<text class="order-time">{{ o.createdAt ? o.createdAt.slice(0,16).replace('T',' ') : '--' }}</text>
|
||
<view class="order-actions" v-if="o.status === 'pending'">
|
||
<text class="sync-btn" @click="syncOrder(o.outTradeNo)">同步</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<text class="load-more" v-if="ordersTotal > orders.length" @click="loadMoreOrders">加载更多</text>
|
||
<text class="empty-text" v-if="orders.length === 0 && !orderLoading">暂无订单</text>
|
||
</view>
|
||
<text class="loading-text" v-if="orderLoading">加载中...</text>
|
||
</view>
|
||
<!-- 定价管理 -->
|
||
<view v-if="tab === 'pricing'" class="section">
|
||
<view class="config-card">
|
||
<view class="cfg-title">产品定价</view>
|
||
<view class="cfg-row">
|
||
<text>AI 面试(元/次)</text>
|
||
<input class="cfg-input" type="digit" v-model.number="pricing.interview.pricePerSession" @blur="calcInterviewPrice" />
|
||
</view>
|
||
<view class="cfg-row">
|
||
<text>简历优化(元/次)</text>
|
||
<input class="cfg-input" type="digit" v-model.number="pricing.resumeOptimize.pricePerOptimize" />
|
||
</view>
|
||
<view class="cfg-row">
|
||
<text>简历下载(元/次)</text>
|
||
<input class="cfg-input" type="digit" v-model.number="pricing.resumeDownload.pricePerDownload" />
|
||
</view>
|
||
<view class="cfg-row">
|
||
<text>免费优化次数</text>
|
||
<input class="cfg-input" type="digit" v-model.number="pricing.resumeOptimize.freeLimit" />
|
||
</view>
|
||
</view>
|
||
|
||
<view class="config-card">
|
||
<view class="cfg-title">成长版 ¥{{ growthPriceDisplay }}</view>
|
||
<view class="cfg-row">
|
||
<text>价格(元/月)</text>
|
||
<input class="cfg-input" type="digit" v-model.number="growthPriceTemp" />
|
||
</view>
|
||
<view class="cfg-row">
|
||
<text>面试额度/月</text>
|
||
<input class="cfg-input" type="digit" v-model.number="pricing.plans.growth.credits.interview" />
|
||
</view>
|
||
<view class="cfg-row">
|
||
<text>优化额度/月</text>
|
||
<input class="cfg-input" type="digit" v-model.number="pricing.plans.growth.credits.resumeOptimize" />
|
||
</view>
|
||
<view class="cfg-row">
|
||
<text>下载额度/月</text>
|
||
<input class="cfg-input" type="digit" v-model.number="pricing.plans.growth.credits.resumeDownload" />
|
||
</view>
|
||
<view class="cfg-row">
|
||
<text>功能列表(每行一个)</text>
|
||
</view>
|
||
<textarea class="cfg-textarea" v-model="growthFeaturesText" placeholder="每行一个功能" />
|
||
</view>
|
||
|
||
<view class="config-card">
|
||
<view class="cfg-title">冲刺版 ¥{{ sprintPriceDisplay }}</view>
|
||
<view class="cfg-row">
|
||
<text>价格(元/月)</text>
|
||
<input class="cfg-input" type="digit" v-model.number="sprintPriceTemp" />
|
||
</view>
|
||
<view class="cfg-row">
|
||
<text>面试额度/月</text>
|
||
<input class="cfg-input" type="digit" v-model.number="pricing.plans.sprint.credits.interview" />
|
||
</view>
|
||
<view class="cfg-row">
|
||
<text>优化额度/月</text>
|
||
<input class="cfg-input" type="digit" v-model.number="pricing.plans.sprint.credits.resumeOptimize" />
|
||
</view>
|
||
<view class="cfg-row">
|
||
<text>下载额度/月</text>
|
||
<input class="cfg-input" type="digit" v-model.number="pricing.plans.sprint.credits.resumeDownload" />
|
||
</view>
|
||
<view class="cfg-row">
|
||
<text>功能列表(每行一个)</text>
|
||
</view>
|
||
<textarea class="cfg-textarea" v-model="sprintFeaturesText" placeholder="每行一个功能" />
|
||
</view>
|
||
|
||
<view class="config-card">
|
||
<view class="cfg-title">其他配置</view>
|
||
<view class="cfg-row">
|
||
<text>会员有效期(天)</text>
|
||
<input class="cfg-input" type="digit" v-model.number="pricing.plans.growth.durationDays" />
|
||
</view>
|
||
</view>
|
||
|
||
<button class="save-btn" @click="savePricing" :disabled="pricingLoading">保存定价配置</button>
|
||
<text class="loading-text" v-if="pricingLoading">保存中...</text>
|
||
</view>
|
||
<!-- 管理员 -->
|
||
<view v-if="tab === 'admins'" class="section">
|
||
<view class="search-bar">
|
||
<input v-model="adminKeyword" placeholder="搜索用户ID或手机号设为管理员" class="search-input" @confirm="searchAdmin" />
|
||
<button class="search-btn" @click="searchAdmin">搜索</button>
|
||
</view>
|
||
<view class="section-label">当前管理员</view>
|
||
<view class="user-list">
|
||
<view class="admin-row" v-for="a in adminList" :key="a._id">
|
||
<text class="admin-phone">{{ a.phone || '--' }}</text>
|
||
<text class="admin-name">{{ a.nickname || '--' }}</text>
|
||
<text class="admin-badge" v-if="a.isSystemAdmin">系统</text>
|
||
</view>
|
||
<text class="empty-text" v-if="adminList.length === 0">暂无管理员</text>
|
||
</view>
|
||
<view class="section-label" v-if="searchResult">搜索结果</view>
|
||
<view class="user-list" v-if="searchResult">
|
||
<view class="admin-row">
|
||
<text class="admin-phone">{{ searchResult.phone || '--' }}</text>
|
||
<text class="admin-name">{{ searchResult.nickname || '--' }}</text>
|
||
<text class="admin-set-btn" v-if="searchResult.role !== 'admin'" @click="setAdmin(searchResult._id)">设为管理员</text>
|
||
<text class="admin-set-btn done" v-else>已是管理员</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed } from 'vue'
|
||
import { api, API_ENDPOINTS } from '../../config'
|
||
|
||
const verified = ref(false)
|
||
const adminName = ref('')
|
||
const tab = ref('overview')
|
||
const loading = ref(false)
|
||
const usersLoading = ref(false)
|
||
const ivLoading = ref(false)
|
||
const userKeyword = ref('')
|
||
const usersPage = ref(1)
|
||
|
||
const overview = ref({ userCount: 0, interviewCount: 0, todayUsers: 0, todayInterviews: 0 })
|
||
const users = ref([])
|
||
const usersTotal = ref(0)
|
||
const interviews = ref([])
|
||
const adminKeyword = ref('')
|
||
const adminList = ref([])
|
||
const searchResult = ref(null)
|
||
const memberConfig = ref({ interview: { maxRoundsFree: 5, maxRoundsVip: 10, dailyFreeLimit: 3 }, diagnosis: { dailyFreeLimit: 2 }, optimize: { dailyFreeLimit: 2 }, price: { monthly: 2900 } })
|
||
const cfgLoading = ref(false)
|
||
const pricing = ref({
|
||
interview: { pricePerSession: 500 },
|
||
resumeOptimize: { freeLimit: 3, pricePerOptimize: 300 },
|
||
resumeDownload: { pricePerDownload: 200 },
|
||
plans: {
|
||
growth: { price: 1990, durationDays: 30, credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }, features: ['免费版全部权益', 'AI 数字人面试无限次'] },
|
||
sprint: { price: 4990, durationDays: 30, credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 }, features: ['成长版全部权益'] },
|
||
},
|
||
})
|
||
const pricingLoading = ref(false)
|
||
const growthPriceTemp = ref(19.9)
|
||
const sprintPriceTemp = ref(49.9)
|
||
const growthFeaturesText = ref('')
|
||
const sprintFeaturesText = ref('')
|
||
|
||
const growthPriceDisplay = computed(() => growthPriceTemp.value.toFixed(1))
|
||
const sprintPriceDisplay = computed(() => sprintPriceTemp.value.toFixed(1))
|
||
|
||
const calcInterviewPrice = () => {
|
||
// Convert to 分 on save
|
||
}
|
||
const orders = ref([])
|
||
const ordersTotal = ref(0)
|
||
const ordersPage = ref(1)
|
||
const orderLoading = ref(false)
|
||
const orderFilter = ref('')
|
||
|
||
const token = () => uni.getStorageSync('token') || ''
|
||
|
||
const apiAdmin = (path, opts = {}) => {
|
||
return uni.request({
|
||
url: api('/admin' + path),
|
||
method: opts.method || 'GET',
|
||
header: { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json', ...opts.headers },
|
||
data: opts.body || opts.data,
|
||
})
|
||
}
|
||
|
||
const doVerify = async () => {
|
||
const t = token()
|
||
if (!t) { uni.navigateTo({ url: '/pages/login/login' }); return }
|
||
try {
|
||
const res = await apiAdmin('/check')
|
||
if (res.statusCode === 200 && res.data?.isAdmin) {
|
||
adminName.value = '管理员'
|
||
verified.value = true
|
||
loadOverview()
|
||
} else throw new Error('无管理员权限')
|
||
} catch (e) {
|
||
uni.showToast({ title: '当前账号非管理员,无权限访问', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
const loadOverview = async () => {
|
||
loading.value = true
|
||
try {
|
||
const res = await apiAdmin('/overview')
|
||
if (res.statusCode === 200) overview.value = res.data
|
||
} catch (e) { console.error(e) }
|
||
finally { loading.value = false }
|
||
}
|
||
|
||
const switchTab = (t) => {
|
||
tab.value = t
|
||
if (t === 'users' && users.value.length === 0) loadUsers()
|
||
if (t === 'interviews' && interviews.value.length === 0) loadInterviews()
|
||
if (t === 'admins' && adminList.value.length === 0) loadAdmins()
|
||
if (t === 'pricing') loadPricing()
|
||
if (t === 'orders') loadOrders()
|
||
}
|
||
|
||
const loadUsers = async () => {
|
||
usersLoading.value = true
|
||
usersPage.value = 1
|
||
try {
|
||
const res = await apiAdmin('/users?keyword=' + encodeURIComponent(userKeyword.value) + '&page=1&limit=20')
|
||
if (res.statusCode === 200) { users.value = res.data.users || []; usersTotal.value = res.data.total || 0 }
|
||
} catch (e) { console.error(e) }
|
||
finally { usersLoading.value = false }
|
||
}
|
||
|
||
const loadMoreUsers = async () => {
|
||
usersPage.value++
|
||
try {
|
||
const res = await apiAdmin('/users?keyword=' + encodeURIComponent(userKeyword.value) + '&page=' + usersPage.value + '&limit=20')
|
||
if (res.statusCode === 200) users.value = [...users.value, ...(res.data.users || [])]
|
||
} catch (e) { console.error(e) }
|
||
}
|
||
|
||
const loadInterviews = async () => {
|
||
ivLoading.value = true
|
||
try {
|
||
const res = await apiAdmin('/interviews?page=1&limit=20')
|
||
if (res.statusCode === 200) interviews.value = res.data.interviews || []
|
||
} catch (e) { console.error(e) }
|
||
finally { ivLoading.value = false }
|
||
}
|
||
|
||
const loadPricing = async () => {
|
||
pricingLoading.value = true
|
||
try {
|
||
const res = await apiAdmin('/pricing')
|
||
if (res.statusCode === 200 && res.data) {
|
||
pricing.value = res.data
|
||
growthPriceTemp.value = (res.data.plans?.growth?.price || 1990) / 100
|
||
sprintPriceTemp.value = (res.data.plans?.sprint?.price || 4990) / 100
|
||
growthFeaturesText.value = (res.data.plans?.growth?.features || []).join('\n')
|
||
sprintFeaturesText.value = (res.data.plans?.sprint?.features || []).join('\n')
|
||
}
|
||
} catch (e) { console.error(e) }
|
||
finally { pricingLoading.value = false }
|
||
}
|
||
|
||
const savePricing = async () => {
|
||
pricingLoading.value = true
|
||
try {
|
||
const data = JSON.parse(JSON.stringify(pricing.value))
|
||
data.plans.growth.price = Math.round(growthPriceTemp.value * 100)
|
||
data.plans.sprint.price = Math.round(sprintPriceTemp.value * 100)
|
||
data.plans.growth.features = growthFeaturesText.value.split('\n').filter(f => f.trim())
|
||
data.plans.sprint.features = sprintFeaturesText.value.split('\n').filter(f => f.trim())
|
||
|
||
const res = await apiAdmin('/pricing/save', { method: 'POST', data })
|
||
if (res.statusCode === 200) {
|
||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||
} else {
|
||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||
}
|
||
} catch (e) {
|
||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||
console.error(e)
|
||
}
|
||
finally { pricingLoading.value = false }
|
||
}
|
||
|
||
const loadConfig = async () => {
|
||
cfgLoading.value = true
|
||
try {
|
||
const res = await apiAdmin('/config')
|
||
if (res.statusCode === 200) memberConfig.value = res.data
|
||
} catch(e) { console.error(e) }
|
||
finally { cfgLoading.value = false }
|
||
}
|
||
|
||
const loadOrders = async () => {
|
||
orderLoading.value = true
|
||
ordersPage.value = 1
|
||
try {
|
||
let url = '/orders?page=1&limit=20'
|
||
if (orderFilter.value) url += '&status=' + orderFilter.value
|
||
const res = await apiAdmin(url)
|
||
if (res.statusCode === 200) { orders.value = res.data.orders || []; ordersTotal.value = res.data.total || 0 }
|
||
} catch(e) { console.error(e) }
|
||
finally { orderLoading.value = false }
|
||
}
|
||
|
||
const loadMoreOrders = async () => {
|
||
ordersPage.value++
|
||
let url = '/orders?page=' + ordersPage.value + '&limit=20'
|
||
if (orderFilter.value) url += '&status=' + orderFilter.value
|
||
try {
|
||
const res = await apiAdmin(url)
|
||
if (res.statusCode === 200) orders.value = [...orders.value, ...(res.data.orders || [])]
|
||
} catch(e) { console.error(e) }
|
||
}
|
||
|
||
const syncOrder = async (outTradeNo) => {
|
||
uni.showToast({ title: '同步中...', icon: 'none' })
|
||
try {
|
||
const res = await apiAdmin('/order/sync', { method: 'POST', data: { outTradeNo } })
|
||
if (res.statusCode === 200) {
|
||
uni.showToast({ title: '同步完成', icon: 'success' })
|
||
loadOrders()
|
||
} else { uni.showToast({ title: '同步失败', icon: 'none' }) }
|
||
} catch { uni.showToast({ title: '同步失败', icon: 'none' }) }
|
||
}
|
||
|
||
const loadAdmins = async () => {
|
||
try {
|
||
const res = await apiAdmin('/admins')
|
||
if (res.statusCode === 200) adminList.value = res.data.admins || []
|
||
} catch(e) { console.error(e) }
|
||
}
|
||
|
||
const searchAdmin = async () => {
|
||
if (!adminKeyword.value.trim()) return
|
||
try {
|
||
const res = await apiAdmin('/users?keyword=' + encodeURIComponent(adminKeyword.value) + '&limit=1')
|
||
if (res.statusCode === 200 && res.data.users?.length > 0) {
|
||
searchResult.value = res.data.users[0]
|
||
} else {
|
||
uni.showToast({ title: '未找到该用户', icon: 'none' })
|
||
searchResult.value = null
|
||
}
|
||
} catch { searchResult.value = null }
|
||
}
|
||
|
||
const setAdmin = async (targetUserId) => {
|
||
uni.showModal({
|
||
title: '设为管理员', content: '确定将该用户设为管理员?', success: async (r) => {
|
||
if (!r.confirm) return
|
||
try {
|
||
const res = await apiAdmin('/set-admin', { method: 'POST', data: { userId: targetUserId } })
|
||
if (res.statusCode === 200) {
|
||
uni.showToast({ title: '已设为管理员', icon: 'success' })
|
||
searchResult.value = null
|
||
adminKeyword.value = ''
|
||
loadAdmins()
|
||
} else throw new Error()
|
||
} catch { uni.showToast({ title: '操作失败', icon: 'none' }) }
|
||
}
|
||
})
|
||
}
|
||
|
||
const setVip = async (targetUserId) => {
|
||
uni.showModal({
|
||
title: '设为会员', content: '确定将该用户升级为月度会员?', success: async (r) => {
|
||
if (!r.confirm) return
|
||
try {
|
||
const res = await apiAdmin('/set-vip', { method: 'POST', data: { userId: targetUserId } })
|
||
if (res.statusCode === 200) {
|
||
uni.showToast({ title: '已设为会员', icon: 'success' })
|
||
loadUsers()
|
||
} else throw new Error()
|
||
} catch { uni.showToast({ title: '操作失败', icon: 'none' }) }
|
||
}
|
||
})
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page { background: var(--color-bg); min-height: 100vh; }
|
||
.hero { background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end)); padding: 48rpx 32rpx 72rpx; border-radius: 0 0 48rpx 48rpx; }
|
||
.hero-title { font-size: 40rpx; font-weight: 700; color: #FFF; }
|
||
.hero-sub { font-size: 22rpx; color: rgba(255,255,255,0.7); margin-top: 8rpx; display: block; }
|
||
.login-area { padding: 32rpx; margin-top: -40rpx; display: flex; flex-direction: column; gap: 16rpx; }
|
||
.admin-input { height: 72rpx; background: #FFF; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 0 20rpx; font-size: 24rpx; }
|
||
.btn-verify { height: 72rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFF; border-radius: var(--radius-sm); font-size: 26rpx; border: none; }
|
||
.body { padding: 20rpx 32rpx 48rpx; margin-top: -40rpx; }
|
||
.tabs { display: flex; gap: 8rpx; background: #FFF; border-radius: var(--radius-md); padding: 6rpx; margin-bottom: 20rpx; }
|
||
.tab { flex: 1; text-align: center; padding: 14rpx; font-size: 24rpx; color: var(--color-text-secondary); border-radius: var(--radius-sm); }
|
||
.tab.active { background: var(--color-primary); color: #FFF; font-weight: 600; }
|
||
.stat-cards { display: flex; gap: 16rpx; }
|
||
.stat-card { flex: 1; background: #FFF; border-radius: var(--radius-lg); padding: 28rpx; text-align: center; box-shadow: var(--shadow-sm); }
|
||
.stat-num { font-size: 48rpx; font-weight: 800; color: var(--color-primary); display: block; }
|
||
.stat-label { font-size: 22rpx; color: var(--color-text-secondary); margin-top: 8rpx; display: block; }
|
||
.stat-sub { font-size: 20rpx; color: var(--color-success); margin-top: 4rpx; display: block; }
|
||
.search-bar { display: flex; gap: 12rpx; margin-bottom: 16rpx; }
|
||
.search-input { flex: 1; height: 64rpx; background: #FFF; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 0 16rpx; font-size: 24rpx; }
|
||
.search-btn { height: 64rpx; padding: 0 24rpx; background: var(--color-primary); color: #FFF; border-radius: var(--radius-sm); font-size: 24rpx; border: none; }
|
||
.user-row { background: #FFF; padding: 20rpx; border-radius: var(--radius-sm); margin-bottom: 8rpx; display: flex; flex-wrap: wrap; gap: 8rpx; }
|
||
.user-phone { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
|
||
.user-name { font-size: 22rpx; color: var(--color-text-secondary); }
|
||
.user-plan { font-size: 20rpx; background: #EEF2FF; color: var(--color-primary); padding: 2rpx 12rpx; border-radius: var(--radius-round); }
|
||
.user-remaining { font-size: 20rpx; color: var(--color-text-tertiary); }
|
||
.loading-text { text-align: center; padding: 40rpx; color: var(--color-text-tertiary); font-size: 24rpx; }
|
||
.load-more { text-align: center; padding: 20rpx; color: var(--color-primary); font-size: 24rpx; display: block; }
|
||
.iv-row { background: #FFF; padding: 20rpx; border-radius: var(--radius-sm); margin-bottom: 8rpx; display: flex; flex-wrap: wrap; gap: 8rpx; align-items: center; }
|
||
.iv-pos { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
|
||
.iv-user { font-size: 22rpx; color: var(--color-text-secondary); }
|
||
.iv-status { font-size: 20rpx; background: #FFF7ED; color: var(--color-warning); padding: 2rpx 12rpx; border-radius: var(--radius-round); }
|
||
.iv-status.done { background: #ECFDF5; color: var(--color-success); }
|
||
.iv-questions { font-size: 20rpx; color: var(--color-text-tertiary); }
|
||
.section-label { font-size: 24rpx; font-weight: 600; color: var(--color-text); margin-bottom: 12rpx; margin-top: 12rpx; }
|
||
.admin-row { background: #FFF; padding: 20rpx; border-radius: var(--radius-sm); margin-bottom: 8rpx; display: flex; flex-wrap: wrap; gap: 8rpx; align-items: center; }
|
||
.admin-phone { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
|
||
.admin-name { font-size: 22rpx; color: var(--color-text-secondary); flex: 1; }
|
||
.admin-set-btn { font-size: 22rpx; color: var(--color-primary); padding: 4rpx 16rpx; border: 2rpx solid var(--color-primary); border-radius: var(--radius-round); }
|
||
.admin-set-btn.done { color: var(--color-success); border-color: var(--color-success); }
|
||
.admin-badge { font-size: 18rpx; background: var(--color-primary); color: #FFF; padding: 2rpx 10rpx; border-radius: var(--radius-round); }
|
||
.empty-text { text-align: center; padding: 20rpx; color: var(--color-text-tertiary); font-size: 22rpx; display: block; }
|
||
.order-list { display: flex; flex-direction: column; gap: 8rpx; }
|
||
.order-row { background: #FFF; border-radius: var(--radius-sm); padding: 16rpx; }
|
||
.order-info { display: flex; justify-content: space-between; margin-bottom: 8rpx; }
|
||
.order-id { font-size: 22rpx; color: var(--color-text); font-weight: 500; }
|
||
.order-user { font-size: 20rpx; color: var(--color-text-tertiary); }
|
||
.order-meta { display: flex; align-items: center; gap: 12rpx; }
|
||
.order-amount { font-size: 28rpx; font-weight: 700; color: var(--color-primary); }
|
||
.order-status { font-size: 20rpx; padding: 2rpx 12rpx; border-radius: var(--radius-round); }
|
||
.order-status.paid { background: #ECFDF5; color: var(--color-success); }
|
||
.order-status.refund { background: #FEF3C7; color: var(--color-warning); }
|
||
.order-status.pend { background: #F3F4F6; color: var(--color-text-tertiary); }
|
||
.order-time { font-size: 20rpx; color: var(--color-text-tertiary); flex: 1; text-align: right; }
|
||
.order-actions { }
|
||
.sync-btn { font-size: 20rpx; color: var(--color-primary); padding: 4rpx 12rpx; border: 2rpx solid var(--color-primary); border-radius: var(--radius-round); }
|
||
.config-card { background: #FFF; border-radius: var(--radius-sm); padding: 20rpx; margin-bottom: 12rpx; }
|
||
.cfg-title { font-size: 24rpx; font-weight: 600; color: var(--color-text); margin-bottom: 12rpx; }
|
||
.cfg-row { display: flex; justify-content: space-between; padding: 8rpx 0; font-size: 22rpx; color: var(--color-text-secondary); align-items: center; }
|
||
.cfg-val { font-weight: 600; color: var(--color-primary); }
|
||
.cfg-input { width: 160rpx; height: 56rpx; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 0 12rpx; font-size: 22rpx; text-align: center; }
|
||
.cfg-textarea { width: 100%; height: 160rpx; border: 2rpx solid var(--color-border); border-radius: var(--radius-sm); padding: 12rpx; font-size: 22rpx; margin-top: 8rpx; box-sizing: border-box; }
|
||
.save-btn { width: 100%; height: 72rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFF; border-radius: var(--radius-sm); font-size: 26rpx; border: none; margin-top: 12rpx; }
|
||
.save-btn:disabled { opacity: 0.6; }
|
||
</style>
|