Files
zhiyin/zhiyin-app/src/pages/admin/admin.vue
T
yuzhiran ef4d22a633 feat(admin): enrich admin panel fields; add user index constraint and customer service
- admin controller: add updatedAt to interview/resume selects; add orderCount,
  todayOrders, totalRevenue to overview
- admin.vue: enrich all tabs with more fields
  - overview: order cards (count, revenue)
  - users: wxOpenid, email, createdAt, interviewCount, vipExpireAt, role badge
  - interviews: user email, updatedAt, summary preview
  - orders: title, type, channel, paidAt, wxTransactionId, refund info
  - resumes: user email, updatedAt
  - share: sharer phone, shareCode, isActive, visitorId(IP), creditedAt
  - admins: email, createdAt
- user.schema: add unique indexes on phone/wxOpenid/email; pre-save hook
  requiring at least one contact method
- user/about: add WeChat contact button (open-type=contact) for customer service
2026-06-20 22:38:33 +08:00

1183 lines
60 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 === 'resumes' }" @click="switchTab('resumes')">简历</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 === 'share' }" @click="switchTab('share')">分享</text>
<text class="tab" :class="{ active: tab === 'positions' }" @click="switchTab('positions')">岗位</text>
<text class="tab" :class="{ active: tab === 'analysis' }" @click="switchTab('analysis')">诊断</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 class="stat-card">
<text class="stat-num">{{ overview.resumeCount ?? 0 }}</text>
<text class="stat-label">总简历</text>
<text class="stat-sub">付费下载 {{ overview.paidDownloadCount ?? 0 }}</text>
</view>
</view>
<view class="stat-cards" style="margin-top:12rpx">
<view class="stat-card">
<text class="stat-num">{{ overview.orderCount ?? 0 }}</text>
<text class="stat-label">总订单</text>
<text class="stat-sub">今日 +{{ overview.todayOrders ?? 0 }}</text>
</view>
<view class="stat-card">
<text class="stat-num">{{ overview.totalRevenue ? '¥' + (overview.totalRevenue / 100).toFixed(1) : '¥0' }}</text>
<text class="stat-label">总营收</text>
<text class="stat-sub">已支付订单合计</text>
</view>
<view class="stat-card" v-if="!(overview.orderCount)">
<text class="stat-num">--</text>
<text class="stat-label">--</text>
<text class="stat-sub" />
</view>
</view>
<view class="plan-cards">
<view class="plan-card" v-for="(cnt, plan) in overview.planBreakdown" :key="plan">
<text class="plan-num">{{ cnt }}</text>
<text class="plan-label">{{ planNameMap[plan] || plan }}</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">
<view class="user-main">
<text class="user-phone">{{ u.phone || '--' }}</text>
<text class="user-name">{{ u.nickname || '--' }}</text>
<text class="user-badge-role" v-if="u.role === 'admin'">管理</text>
</view>
<view class="user-meta-row">
<text class="meta-tag email" v-if="u.email">{{ u.email }}</text>
<text class="meta-tag" v-if="u.wxOpenid">openid:{{ u.wxOpenid.slice(0,12) }}..</text>
</view>
<view class="user-meta-row">
<text class="meta-tag">引力:{{ u.gravity ?? 0 }}</text>
<text class="meta-tag">面试:{{ u.interviewCount ?? 0 }}</text>
<text class="user-plan" :class="{ vip: u.plan === 'growth' || u.plan === 'sprint' }">{{ u.plan === 'growth' || u.plan === 'sprint' ? u.plan==='sprint'?'冲刺':'会员' : '免费' }}</text>
<text class="meta-tag share" v-if="u.shareCredits > 0">分享:{{ u.shareCredits }}</text>
</view>
<view class="user-meta-row time-row">
<text class="time-label">注册:{{ u.createdAt?.slice(0,16).replace('T',' ') }}</text>
<text class="time-label" v-if="u.vipExpireAt">到期:{{ u.vipExpireAt?.slice(0,10) }}</text>
<text class="time-label" v-if="u.sprintExpireAt">冲刺到期:{{ u.sprintExpireAt?.slice(0,10) }}</text>
</view>
<view class="user-actions">
<text class="user-action-btn" v-if="u.plan === 'free'" @click="setVip(u._id)">设为会员</text>
<text class="user-action-btn credit" @click="openCreditModal(u)">调整额度</text>
</view>
</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="search-bar">
<input v-model="ivKeyword" placeholder="搜索岗位/用户名" class="search-input" @confirm="loadInterviews" />
<picker :range="['全部状态','进行中','已完成']" @change="e => { ivStatusFilter=e.detail.value; loadInterviews() }">
<text class="search-btn">{{ ['全部状态','进行中','已完成'][ivStatusFilter] }}</text>
</picker>
</view>
<view class="iv-list" v-if="!ivLoading">
<view class="iv-row" v-for="iv in interviews" :key="iv._id">
<view class="iv-main">
<text class="iv-pos">{{ iv.position }}</text>
<text class="iv-user">{{ iv.userId?.phone || iv.userId?.nickname || '--' }}</text>
<text class="iv-user email" v-if="iv.userId?.email">{{ iv.userId.email }}</text>
</view>
<view class="iv-meta">
<text class="iv-status" :class="{ done: iv.status === 'completed' }">{{ iv.status === 'completed' ? '已完成' : '进行中' }}</text>
<text class="iv-tag">{{ iv.questionCount || 0 }}</text>
<text class="iv-tag score">得分 {{ iv.totalScore ?? '-' }}</text>
<text class="iv-tag filler" v-if="iv.fillerScore != null && iv.fillerScore > 0">语气 {{ iv.fillerScore }}/{{ iv.fillerDensity ?? '-' }}</text>
</view>
<view class="iv-meta">
<text class="time-label">开始:{{ iv.createdAt?.slice(0,16).replace('T',' ') }}</text>
<text class="time-label" v-if="iv.updatedAt && iv.updatedAt !== iv.createdAt">更新:{{ iv.updatedAt?.slice(0,16).replace('T',' ') }}</text>
</view>
<text class="iv-summary" v-if="iv.summary">{{ iv.summary.slice(0,60) }}{{ iv.summary.length > 60 ? '...' : '' }}</text>
</view>
<text class="load-more" v-if="ivTotal > interviews.length" @click="loadMoreInterviews">加载更多</text>
<text class="empty-text" v-if="interviews.length === 0 && !ivLoading">暂无面试记录</text>
</view>
<text class="loading-text" v-if="ivLoading">加载中...</text>
</view>
<!-- 简历 -->
<view v-if="tab === 'resumes'" class="section">
<view class="search-bar">
<input v-model="resumeKeyword" placeholder="搜索简历标题" class="search-input" @confirm="loadResumes" />
<text class="search-btn" @click="loadResumes">搜索</text>
</view>
<view class="resume-list" v-if="!resumeLoading">
<view class="resume-row" v-for="r in resumes" :key="r._id">
<view class="resume-main">
<text class="resume-title">{{ r.title }}</text>
<text class="resume-user">{{ r.userId?.phone || r.userId?.nickname || '--' }}</text>
<text class="resume-user email" v-if="r.userId?.email">{{ r.userId.email }}</text>
</view>
<view class="resume-meta">
<text class="resume-tag">v{{ r.version }}</text>
<text class="resume-tag" v-if="r.targetPosition">{{ r.targetPosition }}</text>
<text class="resume-tag paid" v-if="r.paidDownload">付费下载</text>
</view>
<view class="resume-meta time-row">
<text class="time-label">创建:{{ r.createdAt?.slice(0,16).replace('T',' ') }}</text>
<text class="time-label" v-if="r.updatedAt && r.updatedAt !== r.createdAt">更新:{{ r.updatedAt?.slice(0,16).replace('T',' ') }}</text>
</view>
<view class="resume-actions">
<text class="admin-action-btn del" @click="deleteResume(r._id, r.title)">删除</text>
</view>
</view>
</view>
<text class="loading-text" v-if="resumeLoading">加载中...</text>
<text class="empty-text" v-if="!resumeLoading && resumes.length === 0">暂无简历</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>
<view class="order-status rp" :class="o.status === 'success' ? 'paid' : o.status === 'refunded' ? 'refund' : 'pend'">
{{ o.status === 'success' ? '已支付' : o.status === 'refunded' ? '已退款' : '待支付' }}
</view>
</view>
<view class="order-meta-row">
<text class="order-title">{{ o.title || '--' }}</text>
<text class="order-user">用户: {{ o.userPhone || o.userId?.slice(-6) }}</text>
</view>
<view class="order-meta-row">
<text class="order-amount">¥{{ (o.amount / 100).toFixed(1) }}</text>
<text class="meta-tag">类型:{{ o.type || '--' }}</text>
<text class="meta-tag">渠道:{{ o.channel || '--' }}</text>
</view>
<view class="order-meta-row time-row">
<text class="time-label">创建:{{ o.createdAt?.slice(0,16).replace('T',' ') }}</text>
<text class="time-label" v-if="o.paidAt">支付:{{ o.paidAt?.slice(0,16).replace('T',' ') }}</text>
</view>
<view class="order-meta-row" v-if="o.wxTransactionId">
<text class="time-label">微信单号:{{ o.wxTransactionId }}</text>
</view>
<view class="order-meta-row" v-if="o.status === 'refunded'">
<text class="time-label refund-label">退款:¥{{ (o.refundAmount/100).toFixed(1) }} {{ o.refundedAt?.slice(0,16).replace('T',' ') }}</text>
<text class="time-label" v-if="o.refundReason">原因:{{ o.refundReason }}</text>
</view>
<view class="order-actions-bar">
<text class="sync-btn" v-if="o.status === 'pending'" @click="syncOrder(o.outTradeNo)">同步</text>
<text class="refund-btn" v-if="o.status === 'success'" @click="openRefundModal(o)">退款</text>
<text class="sync-btn" v-if="o.status === 'refunded'" @click="queryRefund(o.outTradeNo)">查询</text>
</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">引力值消耗</view>
<view class="cfg-row">
<text>面试消耗引力值/</text>
<input class="cfg-input" type="digit" v-model.number="pricing.gravityRates.interviewPerUse" />
</view>
<view class="cfg-row">
<text>优化消耗引力值/</text>
<input class="cfg-input" type="digit" v-model.number="pricing.gravityRates.optimizePerUse" />
</view>
<view class="cfg-row">
<text>下载消耗引力值/</text>
<input class="cfg-input" type="digit" v-model.number="pricing.gravityRates.downloadPerUse" />
</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.gravityPerMonth" />
</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.gravityPerMonth" />
</view>
<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 === 'share'" class="section">
<view class="tabs in-tab">
<text class="tab" :class="{ active: shareSubTab === 'records' }" @click="shareSubTab='records';loadShareRecords()">分享记录</text>
<text class="tab" :class="{ active: shareSubTab === 'visitors' }" @click="shareSubTab='visitors';loadShareVisitors()">访问记录</text>
</view>
<view v-if="shareSubTab === 'records'">
<view class="share-list" v-if="!shareLoading">
<view class="share-row" v-for="r in shareRecords" :key="r.shareCode">
<view class="share-main">
<text class="share-title">{{ r.title }}</text>
<text class="share-meta">{{ r.sharer?.phone || r.sharer?.nickname || '--' }} · {{ r.type }}</text>
</view>
<view class="share-meta-row">
<text class="meta-tag">code:{{ r.shareCode }}</text>
<text class="meta-tag" :class="r.isActive ? 'badge-done' : 'badge-pend'">{{ r.isActive ? '启用' : '停用' }}</text>
</view>
<view class="share-stats">
<text>访问 {{ r.visitCount }}</text>
<text class="share-credited">有效 {{ r.creditedCount }}</text>
</view>
<view class="share-meta-row time-row">
<text class="time-label">创建:{{ r.createdAt?.slice(0,16).replace('T',' ') }}</text>
</view>
</view>
</view>
<text class="loading-text" v-if="shareLoading">加载中...</text>
<text class="empty-text" v-if="!shareLoading && shareRecords.length === 0">暂无分享记录</text>
</view>
<view v-if="shareSubTab === 'visitors'">
<view class="share-list" v-if="!shareLoading">
<view class="share-row" v-for="(v, i) in shareVisitors" :key="i">
<view class="share-main">
<text>分享者:{{ v.sharer?.phone || v.sharer?.nickname || '--' }}</text>
<text class="share-meta">访客:{{ v.visitor?.phone || v.visitor?.nickname || '匿名' }}</text>
</view>
<view class="share-meta-row">
<text class="meta-tag">IP:{{ v.visitorId || '--' }}</text>
<text class="meta-tag" :class="v.credited ? 'badge-done' : 'badge-pend'">{{ v.credited ? '已积分' : '未积分' }}</text>
</view>
<view class="share-meta-row time-row">
<text class="time-label">访问:{{ v.createdAt?.slice(0,16).replace('T',' ') }}</text>
<text class="time-label" v-if="v.creditedAt">积分:{{ v.creditedAt?.slice(0,16).replace('T',' ') }}</text>
</view>
</view>
</view>
<text class="loading-text" v-if="shareLoading">加载中...</text>
<text class="empty-text" v-if="!shareLoading && shareVisitors.length === 0">暂无访问记录</text>
</view>
</view>
<!-- 岗位管理 -->
<view v-if="tab === 'positions'" class="section">
<view class="search-bar">
<text class="section-label" style="flex:1;margin:0">岗位列表{{ positions.length }}</text>
<button class="search-btn" @click="openPositionModal(null)">新增岗位</button>
</view>
<view class="position-mgr-list" v-if="!posLoading">
<view class="pos-mgr-row" v-for="p in positions" :key="p._id">
<view class="pos-mgr-main">
<text class="pos-mgr-cat" :class="p.category === 'ai' ? 'cat-ai' : 'cat-tr'">{{ p.category === 'ai' ? 'AI' : '传统' }}</text>
<view class="pos-mgr-body">
<text class="pos-mgr-name">{{ p.name }}</text>
<text class="pos-mgr-meta">{{ p.company || '-' }} · {{ p.salary || '-' }} · sort:{{ p.sort }}</text>
</view>
</view>
<view class="pos-mgr-actions">
<text class="pos-mgr-btn edit" @click="openPositionModal(p)">编辑</text>
<text class="pos-mgr-btn del" @click="deletePosition(p._id, p.name)">删除</text>
</view>
</view>
<text class="empty-text" v-if="positions.length === 0 && !posLoading">暂无岗位</text>
</view>
<text class="loading-text" v-if="posLoading">加载中...</text>
</view>
<!-- 诊断分析 -->
<view v-if="tab === 'analysis'" class="section">
<view class="config-card">
<view class="cfg-title">简历诊断</view>
<view class="cfg-row"><text>总诊断次数</text><text class="cfg-val">{{ analysisStats.totalDiagnoses ?? 0 }}</text></view>
<view class="cfg-row"><text>今日诊断</text><text class="cfg-val">{{ analysisStats.todayDiagnoses ?? 0 }}</text></view>
</view>
<view class="config-card">
<view class="cfg-title">技能缺口分析</view>
<view class="cfg-row"><text>总分析次数</text><text class="cfg-val">{{ analysisStats.totalGapAnalysis ?? 0 }}</text></view>
</view>
<text class="loading-text" v-if="analysisLoading">加载中...</text>
</view>
<!-- 额度调整弹窗 -->
<view class="modal-mask" v-if="creditModal.show" @click="closeCreditModal">
<view class="modal-content" @click.stop>
<text class="modal-title">调整 {{ creditModal.user?.nickname || '用户' }} 的额度</text>
<view class="cfg-row">
<text>引力值</text>
<input class="cfg-input" type="digit" v-model.number="creditGravity" />
</view>
<view class="modal-actions">
<button class="modal-btn cancel" @click="closeCreditModal">取消</button>
<button class="modal-btn confirm" @click="doAdjustCredits">确认调整</button>
</view>
</view>
</view>
<!-- 岗位编辑弹窗 -->
<view class="modal-mask" v-if="posModal.show" @click="closePositionModal">
<view class="modal-content" @click.stop>
<text class="modal-title">{{ posModal.isNew ? '新增岗位' : '编辑岗位' }}</text>
<view class="cfg-row"><text>岗位名称</text><input class="cfg-input" v-model="posForm.name" placeholder="必填" /></view>
<view class="cfg-row"><text>薪资</text><input class="cfg-input" v-model="posForm.salary" placeholder="如 15-25K" /></view>
<view class="cfg-row"><text>公司</text><input class="cfg-input" v-model="posForm.company" placeholder="如 字节跳动" /></view>
<view class="cfg-row"><text>排序</text><input class="cfg-input" type="digit" v-model.number="posForm.sort" /></view>
<view class="cfg-row"><text>分类</text>
<picker :range="['AI岗位','传统岗位']" @change="e => posForm.category = e.detail.value === 0 ? 'ai' : 'traditional'">
<text class="cfg-val">{{ posForm.category === 'ai' ? 'AI岗位' : '传统岗位' }}</text>
</picker>
</view>
<view class="cfg-row"><text>启用</text>
<picker :range="['启用','停用']" @change="e => posForm.active = e.detail.value === 0" :value="posForm.active ? 0 : 1">
<text class="cfg-val">{{ posForm.active ? '启用' : '停用' }}</text>
</picker>
</view>
<view class="cfg-row"><text>岗位描述</text></view>
<textarea class="cfg-textarea" v-model="posForm.description" placeholder="岗位职责描述,每行一个要点" />
<view class="cfg-row"><text>任职要求</text></view>
<textarea class="cfg-textarea" v-model="posForm.requirements" placeholder="任职资格要求,每行一个要点" />
<view class="modal-actions">
<button class="modal-btn cancel" @click="closePositionModal">取消</button>
<button class="modal-btn confirm" @click="savePosition">保存</button>
</view>
</view>
</view>
<!-- 退款弹窗 -->
<view class="modal-mask" v-if="refundModal.show" @click="closeRefundModal">
<view class="modal-content" @click.stop>
<text class="modal-title">退款 - {{ refundModal.order?.outTradeNo }}</text>
<view class="cfg-row">
<text>订单金额</text>
<text class="cfg-val">¥{{ ((refundModal.order?.amount || 0) / 100).toFixed(1) }}</text>
</view>
<view class="cfg-row">
<text>退款金额</text>
<input class="cfg-input" type="digit" v-model.number="refundAmount" :placeholder="((refundModal.order?.amount || 0) / 100).toFixed(1)" />
</view>
<view class="cfg-row">
<text>退款原因</text>
<input class="cfg-input" style="width:300rpx" v-model="refundReason" placeholder="选填" />
</view>
<view class="modal-actions">
<button class="modal-btn cancel" @click="closeRefundModal">取消</button>
<button class="modal-btn confirm" style="background:#EF4444" @click="doRefund">确认退款</button>
</view>
</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-email" v-if="a.email">{{ a.email }}</text>
<text class="admin-badge" v-if="a.isSystemAdmin">系统</text>
<text class="time-label" style="margin-left:auto">设置:{{ a.createdAt?.slice(0,10) }}</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-email" v-if="searchResult.email">{{ searchResult.email }}</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>
</template>
<script setup>
import { ref, computed, onMounted, reactive } from 'vue'
import { api, API_ENDPOINTS } from '../../config'
const verified = ref(false)
const adminName = ref('')
const tab = ref('overview')
const shareSubTab = ref('records')
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, resumeCount: 0, paidDownloadCount: 0, planBreakdown: {} })
const planNameMap = { free: '免费', growth: '成长', sprint: '冲刺', vip: '会员' }
const users = ref([])
const usersTotal = ref(0)
const interviews = ref([])
const resumes = ref([])
const resumeLoading = ref(false)
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: 1990 } })
const cfgLoading = ref(false)
const pricing = ref({
interview: { pricePerSession: 500 },
resumeOptimize: { freeLimit: 3, pricePerOptimize: 300 },
resumeDownload: { pricePerDownload: 200 },
gravityRates: { interviewPerUse: 5, optimizePerUse: 3, downloadPerUse: 2 },
plans: {
growth: { price: 1990, durationDays: 30, gravityPerMonth: 250, credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }, features: ['免费版全部权益', '每月 250 引力值'] },
sprint: { price: 4990, durationDays: 30, gravityPerMonth: 600, credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 }, features: ['成长版全部权益', '每月 600 引力值'] },
},
})
const pricingLoading = ref(false)
const growthPriceTemp = ref(19.9)
const sprintPriceTemp = ref(49.9)
const growthFeaturesText = ref('')
const sprintFeaturesText = ref('')
const ivKeyword = ref('')
const ivStatusFilter = ref(0)
const ivTotal = ref(0)
const ivPage = ref(1)
const resumeKeyword = ref('')
const creditGravity = ref(0)
const analysisStats = ref({ totalDiagnoses: 0, todayDiagnoses: 0, totalGapAnalysis: 0 })
const analysisLoading = ref(false)
// Position management
const positions = ref([])
const posLoading = ref(false)
const posModal = ref({ show: false, isNew: false })
const posForm = reactive({
name: '',
salary: '',
company: '',
icon: '',
sort: 0,
active: true,
category: 'ai',
description: '',
requirements: '',
})
const growthPriceDisplay = computed(() => growthPriceTemp.value.toFixed(1))
const sprintPriceDisplay = computed(() => sprintPriceTemp.value.toFixed(1))
const calcInterviewPrice = () => {
// Handled in savePricing via growthPriceTemp / sprintPriceTemp
}
const orders = ref([])
const ordersTotal = ref(0)
const ordersPage = ref(1)
const orderLoading = ref(false)
const orderFilter = ref('')
// Share state
const shareRecords = ref([])
const shareVisitors = ref([])
const shareLoading = ref(false)
// Credit modal
const creditModal = ref({ show: false, user: null })
// Refund modal
const refundModal = ref({ show: false, order: null })
const refundAmount = ref(0)
const refundReason = ref('')
const openRefundModal = (order) => {
refundModal.value = { show: true, order }
refundAmount.value = order.amount / 100
refundReason.value = ''
}
const closeRefundModal = () => {
refundModal.value = { show: false, order: null }
}
const doRefund = async () => {
const order = refundModal.value.order
if (!order) return
uni.showModal({
title: '确认退款', content: `确定对订单 ${order.outTradeNo} 退款 ¥${refundAmount.value.toFixed(1)}`,
success: async (r) => {
if (!r.confirm) return
try {
const res = await apiAdmin('/order/refund', {
method: 'POST',
body: { outTradeNo: order.outTradeNo, amount: Math.round(refundAmount.value * 100), reason: refundReason.value },
})
if (res.statusCode === 200) {
uni.showToast({ title: '退款成功', icon: 'success' })
closeRefundModal()
loadOrders()
} else {
uni.showToast({ title: res.data?.message || '退款失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '退款失败', icon: 'none' })
}
},
})
}
const queryRefund = async (outTradeNo) => {
uni.showToast({ title: '查询中...', icon: 'none' })
try {
const res = await apiAdmin('/order/refund/' + outTradeNo)
if (res.statusCode === 200) {
const wx = res.data.wxRefund
if (wx) {
uni.showModal({
title: '退款状态',
content: `微信状态: ${wx.status || '--'}\n退款金额: ¥${(wx.amount?.refund || 0) / 100}\n建议以微信侧为准`,
})
} else {
uni.showToast({ title: '本地状态: ' + (res.data.localStatus || '--'), icon: 'none' })
}
} else {
uni.showToast({ title: '查询失败', icon: 'none' })
}
} catch { uni.showToast({ title: '查询失败', icon: 'none' }) }
}
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 = res.data.nickname || res.data.username || '管理员'
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 === 'positions') loadPositions()
if (t === 'resumes' && resumes.value.length === 0) loadResumes()
if (t === 'admins' && adminList.value.length === 0) loadAdmins()
if (t === 'pricing') loadPricing()
if (t === 'orders') loadOrders()
if (t === 'analysis') loadAnalysis()
}
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
ivPage.value = 1
try {
let url = '/interviews?page=1&limit=20&keyword=' + encodeURIComponent(ivKeyword.value)
const statusMap = ['', 'in_progress', 'completed']
if (ivStatusFilter.value > 0) url += '&status=' + statusMap[ivStatusFilter.value]
const res = await apiAdmin(url)
if (res.statusCode === 200) { interviews.value = res.data.interviews || []; ivTotal.value = res.data.total || 0 }
} catch (e) { console.error(e) }
finally { ivLoading.value = false }
}
const loadMoreInterviews = async () => {
ivPage.value++
try {
let url = '/interviews?page=' + ivPage.value + '&limit=20&keyword=' + encodeURIComponent(ivKeyword.value)
const statusMap = ['', 'in_progress', 'completed']
if (ivStatusFilter.value > 0) url += '&status=' + statusMap[ivStatusFilter.value]
const res = await apiAdmin(url)
if (res.statusCode === 200) interviews.value = [...interviews.value, ...(res.data.interviews || [])]
} catch (e) { console.error(e) }
}
const loadResumes = async () => {
resumeLoading.value = true
try {
let url = '/resumes?page=1&limit=20'
if (resumeKeyword.value) url += '&keyword=' + encodeURIComponent(resumeKeyword.value)
const res = await apiAdmin(url)
if (res.statusCode === 200) resumes.value = res.data.list || []
} catch (e) { console.error(e) }
finally { resumeLoading.value = false }
}
const deleteResume = (id, title) => {
uni.showModal({
title: '删除简历', content: `确定删除"${title}"`,
success: async (r) => {
if (!r.confirm) return
try {
const res = await apiAdmin('/resume/' + id, { method: 'DELETE' })
if (res.statusCode === 200) {
uni.showToast({ title: '已删除', icon: 'success' })
loadResumes()
} else throw new Error()
} catch { uni.showToast({ title: '删除失败', icon: 'none' }) }
},
})
}
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 loadAnalysis = async () => {
analysisLoading.value = true
try {
const res = await apiAdmin('/analysis-stats')
if (res.statusCode === 200) analysisStats.value = res.data
} catch(e) { console.error(e) }
finally { analysisLoading.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 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 apiPositions = (path, opts = {}) => {
return uni.request({
url: api('/positions' + path),
method: opts.method || 'POST',
header: { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json', ...opts.headers },
data: opts.body || opts.data,
})
}
const loadPositions = async () => {
posLoading.value = true
try {
const res = await apiPositions('/admin/list')
if (res.statusCode === 200) positions.value = res.data || []
} catch (e) { console.error(e) }
finally { posLoading.value = false }
}
const openPositionModal = (position) => {
if (position) {
posForm.name = position.name || ''
posForm.salary = position.salary || ''
posForm.company = position.company || ''
posForm.icon = position.icon || ''
posForm.sort = position.sort ?? 0
posForm.active = position.active ?? true
posForm.category = position.category || 'traditional'
posForm.description = position.description || ''
posForm.requirements = position.requirements || ''
posModal.value = { show: true, isNew: false }
} else {
posForm.name = ''
posForm.salary = ''
posForm.company = ''
posForm.icon = ''
posForm.sort = positions.value.length + 1
posForm.active = true
posForm.category = 'ai'
posForm.description = ''
posForm.requirements = ''
posModal.value = { show: true, isNew: true }
}
}
const closePositionModal = () => {
posModal.value = { show: false, isNew: false }
}
const savePosition = async () => {
if (!posForm.name.trim()) {
uni.showToast({ title: '岗位名称不能为空', icon: 'none' })
return
}
try {
const res = await apiPositions('/admin/save', {
method: 'POST', body: { ...posForm },
})
if (res.statusCode === 200) {
uni.showToast({ title: '保存成功', icon: 'success' })
closePositionModal()
loadPositions()
} else throw new Error()
} catch { uni.showToast({ title: '保存失败', icon: 'none' }) }
}
const deletePosition = (id, name) => {
uni.showModal({
title: '删除岗位', content: `确定删除"${name}"`,
success: async (r) => {
if (!r.confirm) return
try {
const res = await apiPositions('/admin/' + id, { method: 'DELETE' })
if (res.statusCode === 200) {
uni.showToast({ title: '已删除', icon: 'success' })
loadPositions()
} else throw new Error()
} catch { uni.showToast({ title: '删除失败', icon: 'none' }) }
},
})
}
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' }) }
}
})
}
const loadShareRecords = async () => {
shareLoading.value = true
try {
const res = await apiAdmin('/share-records?page=1&limit=50')
if (res.statusCode === 200) shareRecords.value = res.data.list || []
} catch(e) { console.error(e) }
finally { shareLoading.value = false }
}
const loadShareVisitors = async () => {
shareLoading.value = true
try {
const res = await apiAdmin('/share-visitors?page=1&limit=50')
if (res.statusCode === 200) shareVisitors.value = res.data.list || []
} catch(e) { console.error(e) }
finally { shareLoading.value = false }
}
const openCreditModal = (user) => {
creditGravity.value = user.gravity ?? 0
creditModal.value = { show: true, user }
}
const closeCreditModal = () => {
creditModal.value = { show: false, user: null }
}
const doAdjustCredits = async () => {
const userId = creditModal.value.user?._id
if (!userId) return
try {
await apiAdmin('/user/credits', {
method: 'POST',
data: { userId, type: 'gravity', amount: creditGravity.value },
})
uni.showToast({ title: '调整成功', icon: 'success' })
closeCreditModal()
loadUsers()
} catch { uni.showToast({ title: '调整失败', icon: 'none' }) }
}
onMounted(() => { doVerify() })
</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; flex-wrap: wrap; gap: 8rpx; background: #FFF; border-radius: var(--radius-md); padding: 6rpx; margin-bottom: 20rpx; }
.tab { padding: 14rpx 20rpx; font-size: 24rpx; color: var(--color-text-secondary); border-radius: var(--radius-sm); white-space: nowrap; }
.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; }
.plan-cards { display: flex; gap: 12rpx; margin-top: 16rpx; }
.plan-card { flex: 1; background: #FFF; border-radius: var(--radius-sm); padding: 20rpx; text-align: center; box-shadow: var(--shadow-sm); }
.plan-num { font-size: 36rpx; font-weight: 700; color: var(--color-primary); display: block; }
.plan-label { font-size: 20rpx; color: var(--color-text-secondary); 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; }
.user-main { display: flex; gap: 12rpx; margin-bottom: 8rpx; }
.user-phone { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
.user-name { font-size: 22rpx; color: var(--color-text-secondary); }
.user-badges { display: flex; flex-wrap: wrap; gap: 6rpx; margin-bottom: 8rpx; }
.user-plan { font-size: 20rpx; background: #EEF2FF; color: var(--color-primary); padding: 2rpx 12rpx; border-radius: var(--radius-round); }
.user-plan.vip { background: #FEF3C7; color: #D97706; }
.user-credit { font-size: 18rpx; background: #F3F4F6; color: var(--color-text-tertiary); padding: 2rpx 10rpx; border-radius: var(--radius-round); }
.user-credit.share { background: #FFF7ED; color: #D97706; }
.user-actions { display: flex; gap: 12rpx; }
.user-action-btn { font-size: 20rpx; color: var(--color-primary); padding: 4rpx 16rpx; border: 2rpx solid var(--color-primary); border-radius: var(--radius-round); }
.user-action-btn.credit { color: #D97706; border-color: #D97706; }
.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: 16rpx; border-radius: var(--radius-sm); margin-bottom: 8rpx; }
.iv-main { display: flex; gap: 12rpx; margin-bottom: 6rpx; }
.iv-pos { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
.iv-user { font-size: 22rpx; color: var(--color-text-secondary); }
.iv-meta { display: flex; flex-wrap: wrap; gap: 6rpx; }
.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-tag { font-size: 18rpx; background: #F3F4F6; color: var(--color-text-tertiary); padding: 2rpx 10rpx; border-radius: var(--radius-round); }
.iv-tag.score { background: #EEF2FF; color: var(--color-primary); }
.iv-tag.filler { background: #FFF7ED; color: #D97706; }
.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; }
.resume-list { display: flex; flex-direction: column; gap: 8rpx; }
.resume-row { background: #FFF; padding: 16rpx; border-radius: var(--radius-sm); display: flex; align-items: center; gap: 12rpx; }
.resume-main { flex: 1; display: flex; flex-direction: column; }
.resume-title { font-size: 22rpx; font-weight: 600; color: var(--color-text); }
.resume-user { font-size: 20rpx; color: var(--color-text-secondary); margin-top: 2rpx; }
.resume-meta { display: flex; gap: 6rpx; }
.resume-tag { font-size: 18rpx; background: #F3F4F6; color: var(--color-text-tertiary); padding: 2rpx 10rpx; border-radius: var(--radius-round); }
.resume-tag.paid { background: #FEF3C7; color: #D97706; }
.resume-time { font-size: 18rpx; color: #D1D5DB; white-space: nowrap; }
.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); }
.refund-btn { font-size: 20rpx; color: #EF4444; padding: 4rpx 12rpx; border: 2rpx solid #EF4444; 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; }
.in-tab { margin-bottom: 16rpx; }
.share-list { display: flex; flex-direction: column; gap: 8rpx; }
.share-row { background: #FFF; padding: 16rpx; border-radius: var(--radius-sm); display: flex; align-items: center; gap: 12rpx; }
.share-main { flex: 1; display: flex; flex-direction: column; }
.share-title { font-size: 22rpx; color: var(--color-text); font-weight: 500; }
.share-meta { font-size: 18rpx; color: var(--color-text-tertiary); margin-top: 2rpx; }
.share-stats { display: flex; flex-direction: column; align-items: flex-end; font-size: 20rpx; color: var(--color-text-tertiary); }
.share-credited { color: var(--color-primary); }
.share-time { font-size: 18rpx; color: #D1D5DB; white-space: nowrap; }
.badge { font-size: 18rpx; padding: 2rpx 10rpx; border-radius: 6rpx; }
.badge-done { background: #ECFDF5; color: #059669; }
.badge-pend { background: #FEF3C7; color: #D97706; }
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 999; }
.modal-content { background: #FFF; border-radius: var(--radius-lg); padding: 40rpx 32rpx; width: 600rpx; max-width: 90vw; }
.modal-title { font-size: 28rpx; font-weight: 600; color: var(--color-text); margin-bottom: 24rpx; }
.modal-actions { display: flex; gap: 16rpx; margin-top: 32rpx; }
.modal-btn { flex: 1; height: 72rpx; border-radius: var(--radius-sm); font-size: 26rpx; border: none; }
.modal-btn.cancel { background: #F3F4F6; color: var(--color-text-secondary); }
.modal-btn.confirm { background: var(--color-primary); color: #FFF; }
/* ─── 岗位管理 ───── */
.position-mgr-list { display: flex; flex-direction: column; gap: 8rpx; }
.pos-mgr-row { background: #FFF; padding: 16rpx; border-radius: var(--radius-sm); display: flex; align-items: center; gap: 12rpx; }
.pos-mgr-main { flex: 1; display: flex; align-items: center; gap: 12rpx; }
.pos-mgr-cat { font-size: 18rpx; padding: 2rpx 12rpx; border-radius: var(--radius-round); font-weight: 600; }
.pos-mgr-cat.cat-ai { background: #EEF2FF; color: var(--color-primary); }
.pos-mgr-cat.cat-tr { background: #F3F4F6; color: var(--color-text-tertiary); }
.pos-mgr-body { display: flex; flex-direction: column; }
.pos-mgr-name { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
.pos-mgr-meta { font-size: 20rpx; color: var(--color-text-tertiary); margin-top: 2rpx; }
.pos-mgr-actions { display: flex; gap: 8rpx; }
.pos-mgr-btn { font-size: 20rpx; padding: 4rpx 16rpx; border-radius: var(--radius-round); }
.pos-mgr-btn.edit { color: var(--color-primary); border: 2rpx solid var(--color-primary); }
.pos-mgr-btn.del { color: #EF4444; border: 2rpx solid #EF4444; }
.iv-time { font-size: 18rpx; color: #D1D5DB; white-space: nowrap; margin-left: auto; }
.admin-action-btn { font-size: 20rpx; padding: 4rpx 16rpx; border-radius: var(--radius-round); cursor: pointer; }
.admin-action-btn.del { color: #EF4444; border: 2rpx solid #EF4444; }
.resume-actions { display: flex; gap: 8rpx; align-items: center; }
/* ─── 新增字段样式 ───── */
.user-meta-row { display: flex; flex-wrap: wrap; gap: 6rpx; margin-bottom: 6rpx; }
.meta-tag { font-size: 18rpx; background: #F3F4F6; color: var(--color-text-tertiary); padding: 2rpx 10rpx; border-radius: var(--radius-round); }
.meta-tag.email { background: #EEF2FF; color: var(--color-primary); }
.meta-tag.share { background: #FFF7ED; color: #D97706; }
.meta-tag.badge-done { background: #ECFDF5; color: #059669; }
.meta-tag.badge-pend { background: #FEF3C7; color: #D97706; }
.time-row { display: flex; flex-wrap: wrap; gap: 12rpx; }
.time-label { font-size: 18rpx; color: #9CA3AF; }
.iv-summary { font-size: 18rpx; color: #6B7280; margin-top: 4rpx; line-height: 1.4; display: block; }
.iv-user.email { font-size: 18rpx; color: #6B7280; }
.user-badge-role { font-size: 18rpx; background: #FEF3C7; color: #D97706; padding: 0 10rpx; border-radius: var(--radius-round); font-weight: 500; }
.share-meta-row { display: flex; gap: 6rpx; margin-top: 4rpx; }
.share-meta-row.time-row { gap: 12rpx; }
.order-meta-row { display: flex; flex-wrap: wrap; gap: 8rpx; margin-bottom: 4rpx; }
.order-meta-row.time-row { gap: 12rpx; }
.order-title { font-size: 22rpx; font-weight: 500; color: var(--color-text); }
.order-status.rp { font-size: 18rpx; display: inline-block; }
.refund-label { color: #EF4444 !important; }
.order-actions-bar { display: flex; gap: 8rpx; margin-top: 6rpx; }
.admin-email { font-size: 20rpx; color: #6B7280; }
.resume-user.email { font-size: 18rpx; color: #6B7280; }
</style>