初始化:职引项目 v1.0
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
<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 === 'admins' }" @click="switchTab('admins')">管理员</text>
|
||||
<text class="tab" :class="{ active: tab === 'config' }" @click="switchTab('config')">配置</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 === 'config'" class="section">
|
||||
<view class="config-card" v-if="!cfgLoading">
|
||||
<view class="cfg-title">面试限制</view>
|
||||
<view class="cfg-row"><text>免费版每场最大轮次</text><text class="cfg-val">{{ memberConfig.interview.maxRoundsFree }}</text></view>
|
||||
<view class="cfg-row"><text>会员每场最大轮次</text><text class="cfg-val">{{ memberConfig.interview.maxRoundsVip }}</text></view>
|
||||
<view class="cfg-row"><text>免费版每日面试次数</text><text class="cfg-val">{{ memberConfig.interview.dailyFreeLimit }}</text></view>
|
||||
</view>
|
||||
<view class="config-card" v-if="!cfgLoading">
|
||||
<view class="cfg-title">诊断与优化限制</view>
|
||||
<view class="cfg-row"><text>免费版每日诊断次数</text><text class="cfg-val">{{ memberConfig.diagnosis.dailyFreeLimit }}</text></view>
|
||||
<view class="cfg-row"><text>免费版每日优化次数</text><text class="cfg-val">{{ memberConfig.optimize.dailyFreeLimit }}</text></view>
|
||||
</view>
|
||||
<view class="config-card" v-if="!cfgLoading">
|
||||
<view class="cfg-title">价格</view>
|
||||
<view class="cfg-row"><text>月度会员</text><text class="cfg-val">¥{{ (memberConfig.price.monthly / 100).toFixed(0) }}</text></view>
|
||||
</view>
|
||||
<view class="empty-text" v-if="cfgLoading">加载中...</view>
|
||||
</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 } from 'vue'
|
||||
import { api } 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 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 === 'config') loadConfig()
|
||||
}
|
||||
|
||||
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 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 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; }
|
||||
.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); }
|
||||
.cfg-val { font-weight: 600; color: var(--color-primary); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user