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
This commit is contained in:
yuzhiran
2026-06-20 22:38:33 +08:00
parent 8ee27fdd32
commit ef4d22a633
5 changed files with 165 additions and 38 deletions
+15
View File
@@ -17,6 +17,15 @@
<text class="info-value">contact@yuzhiran.com</text>
</view>
<!-- #ifdef MP-WEIXIN -->
<button class="contact-btn" open-type="contact">
<view class="contact-btn-inner">
<text class="contact-icon">💬</text>
<text class="contact-text">联系在线客服</text>
</view>
</button>
<!-- #endif -->
<view class="link-section">
<view class="link-item" @click="goAgreement">
<text class="link-text">用户协议</text>
@@ -59,4 +68,10 @@ const goPrivacy = () => uni.navigateTo({ url: '/pages/privacy/privacy' })
.disclaimer { margin-top: 40rpx; background: #FFF8E1; border-radius: var(--radius-md); padding: 24rpx; }
.disclaimer-title { font-size: 24rpx; font-weight: 700; color: #F59E0B; display: block; margin-bottom: 12rpx; }
.disclaimer-text { font-size: 22rpx; color: var(--color-text-secondary); line-height: 1.8; }
.contact-btn { width: 100%; background: #FFF; border: none; border-radius: var(--radius-md); padding: 0; margin-bottom: 12rpx; }
.contact-btn-inner { display: flex; align-items: center; gap: 16rpx; padding: 24rpx 30rpx; }
.contact-btn:active { opacity: 0.7; }
.contact-icon { font-size: 28rpx; }
.contact-text { font-size: 26rpx; color: var(--color-text); }
</style>
+116 -28
View File
@@ -45,6 +45,23 @@
<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>
@@ -64,11 +81,22 @@
<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-badges">
<text class="user-plan" :class="{ vip: u.plan === 'growth' || u.plan === 'sprint' }">{{ u.plan === 'growth' || u.plan === 'sprint' ? '会员' : '免费' }}</text>
<text class="user-credit">引力值:{{ u.gravity ?? 0 }}</text>
<text class="user-credit share" v-if="u.shareCredits > 0">分享:{{ u.shareCredits }}</text>
<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>
@@ -93,14 +121,19 @@
<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>
<text class="iv-tag filler" v-if="iv.fillerScore != null && iv.fillerScore > 0"> {{ iv.fillerScore }}/{{ iv.fillerDensity ?? '-' }}</text>
</view>
<text class="iv-time">{{ iv.createdAt?.slice(0,16).replace('T',' ') }}</text>
<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>
@@ -119,13 +152,17 @@
<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>
<text class="resume-time">{{ r.createdAt?.slice(0,10) }}</text>
<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>
@@ -148,20 +185,35 @@
<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'">
<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>
<text class="order-time">{{ o.createdAt ? o.createdAt.slice(0,16).replace('T',' ') : '--' }}</text>
<view class="order-actions">
<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>
<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>
@@ -283,13 +335,19 @@
<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?.nickname || '--' }} · {{ r.type }}</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>
<text class="share-time">{{ r.createdAt?.slice(0,16).replace('T',' ') }}</text>
<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>
@@ -299,13 +357,17 @@
<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?.nickname || '--' }}</text>
<text class="share-meta">访客: {{ v.visitor?.nickname || '匿名' }}</text>
<text>分享者:{{ v.sharer?.phone || v.sharer?.nickname || '--' }}</text>
<text class="share-meta">访客:{{ v.visitor?.phone || v.visitor?.nickname || '匿名' }}</text>
</view>
<view class="share-stats">
<text class="badge" :class="v.credited ? 'badge-done' : 'badge-pend'">{{ v.credited ? '已积分' : '未积分' }}</text>
<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>
<text class="share-time">{{ v.createdAt?.slice(0,16).replace('T',' ') }}</text>
</view>
</view>
<text class="loading-text" v-if="shareLoading">加载中...</text>
@@ -429,7 +491,9 @@
<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>
@@ -438,6 +502,7 @@
<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>
@@ -1088,7 +1153,30 @@ onMounted(() => { doVerify() })
.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 { 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; }
</style>
/* ─── 新增字段样式 ───── */
.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>
+10
View File
@@ -97,6 +97,13 @@
</view>
</view>
<view class="menu-group">
<!-- #ifdef MP-WEIXIN -->
<button class="menu-item contact-btn" open-type="contact">
<view class="menu-icon-wrap wrap-gray"><text class="menu-icon">💬</text></view>
<text class="menu-text">联系客服</text>
<text class="menu-arrow"></text>
</button>
<!-- #endif -->
<view class="menu-item" @click="goAbout">
<view class="menu-icon-wrap wrap-gray"><text class="menu-icon"></text></view>
<text class="menu-text">关于</text>
@@ -322,6 +329,9 @@ const doLogout = () => {
.menu-area { padding: 0 32rpx 32rpx; margin-top: 8rpx; }
.menu-group { background: #FFFFFF; border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 24rpx; box-shadow: var(--shadow-sm); }
.menu-item { display: flex; align-items: center; padding: 28rpx 32rpx; border-bottom: 1rpx solid var(--color-border); }
.contact-btn { width: 100%; background: transparent; border: none; border-radius: 0; padding: 0; margin: 0; line-height: inherit; font-size: inherit; text-align: left; }
.contact-btn::after { border: none; }
.contact-btn:active { background: #F9FAFB; }
.menu-item:last-child { border-bottom: none; }
.menu-item:active { background: #F9FAFB; }
.menu-icon-wrap { width: 60rpx; height: 60rpx; border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; margin-right: 20rpx; flex-shrink: 0; }