diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts index d0678ce..c7540a4 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -48,7 +48,7 @@ export class AdminController { const [ userCount, interviewCount, todayUsers, todayInterviews, resumeCount, paidDownloadCount, - planStats, + planStats, orderCount, todayOrders, totalRevenue, ] = await Promise.all([ this.userModel.countDocuments().exec(), this.interviewModel.countDocuments().exec(), @@ -59,12 +59,19 @@ export class AdminController { this.userModel.aggregate([ { $group: { _id: '$plan', count: { $sum: 1 } } }, ]).exec(), + this.orderModel.countDocuments().exec(), + this.orderModel.countDocuments({ status: 'success', createdAt: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } }).exec(), + this.orderModel.aggregate([ + { $match: { status: 'success' } }, + { $group: { _id: null, total: { $sum: '$amount' } } }, + ]).exec(), ]) const planBreakdown: Record = {} planStats.forEach(p => { planBreakdown[p._id || 'free'] = p.count }) return { userCount, interviewCount, todayUsers, todayInterviews, - resumeCount, paidDownloadCount, + resumeCount, paidDownloadCount, orderCount, todayOrders, + totalRevenue: totalRevenue[0]?.total || 0, planBreakdown, } } @@ -95,8 +102,8 @@ export class AdminController { this.interviewModel.find() .sort({ createdAt: -1 }) .skip(skip).limit(+limit) - .populate('userId', 'phone nickname') - .select('position status totalScore questionCount fillerScore fillerDensity summary createdAt') + .populate('userId', 'phone nickname email wxOpenid') + .select('position status totalScore questionCount fillerScore fillerDensity summary createdAt updatedAt') .lean().exec(), this.interviewModel.countDocuments().exec(), ]) @@ -217,8 +224,8 @@ export class AdminController { this.resumeModel.find() .sort({ createdAt: -1 }) .skip(skip).limit(+limit) - .populate('userId', 'phone nickname') - .select('title targetPosition version paidDownload createdAt') + .populate('userId', 'phone nickname email') + .select('title targetPosition version paidDownload createdAt updatedAt contentHash') .lean().exec(), this.resumeModel.countDocuments().exec(), ]) diff --git a/backend/src/modules/user/user.schema.ts b/backend/src/modules/user/user.schema.ts index 2ed440e..1eaa013 100644 --- a/backend/src/modules/user/user.schema.ts +++ b/backend/src/modules/user/user.schema.ts @@ -5,10 +5,10 @@ export type UserDocument = User & Document @Schema({ timestamps: true }) export class User { - @Prop({ sparse: true }) + @Prop({ unique: true, sparse: true }) phone?: string - @Prop({ sparse: true }) + @Prop({ unique: true, sparse: true }) wxOpenid?: string @Prop({ default: '' }) @@ -60,11 +60,18 @@ export class User { @Prop({ default: false }) isSystemAdmin: boolean - @Prop({ sparse: true }) + @Prop({ unique: true, sparse: true }) email?: string @Prop({ default: '', select: false }) password?: string } -export const UserSchema = SchemaFactory.createForClass(User) \ No newline at end of file +export const UserSchema = SchemaFactory.createForClass(User) + +UserSchema.pre('save', function (next) { + if (!this.phone && !this.wxOpenid && !this.email) { + return next(new Error('用户必须至少有一个联系方式(手机号/微信/邮箱)')) + } + next() +}) \ No newline at end of file diff --git a/zhiyin-app/src/pages/about/about.vue b/zhiyin-app/src/pages/about/about.vue index 0cebed7..b6fae16 100644 --- a/zhiyin-app/src/pages/about/about.vue +++ b/zhiyin-app/src/pages/about/about.vue @@ -17,6 +17,15 @@ contact@yuzhiran.com + + + + 用户协议 @@ -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); } diff --git a/zhiyin-app/src/pages/admin/admin.vue b/zhiyin-app/src/pages/admin/admin.vue index 55840aa..1530f37 100644 --- a/zhiyin-app/src/pages/admin/admin.vue +++ b/zhiyin-app/src/pages/admin/admin.vue @@ -45,6 +45,23 @@ 付费下载 {{ overview.paidDownloadCount ?? 0 }} + + + {{ overview.orderCount ?? 0 }} + 总订单 + 今日 +{{ overview.todayOrders ?? 0 }} + + + {{ overview.totalRevenue ? '¥' + (overview.totalRevenue / 100).toFixed(1) : '¥0' }} + 总营收 + 已支付订单合计 + + + -- + -- + + + {{ cnt }} @@ -64,11 +81,22 @@ {{ u.phone || '--' }} {{ u.nickname || '--' }} + 管理 - - {{ u.plan === 'growth' || u.plan === 'sprint' ? '会员' : '免费' }} - 引力值:{{ u.gravity ?? 0 }} - + + + openid:{{ u.wxOpenid.slice(0,12) }}.. + + + 引力:{{ u.gravity ?? 0 }} + 面试:{{ u.interviewCount ?? 0 }}次 + {{ u.plan === 'growth' || u.plan === 'sprint' ? u.plan==='sprint'?'冲刺':'会员' : '免费' }} + + + + 注册:{{ u.createdAt?.slice(0,16).replace('T',' ') }} + 到期:{{ u.vipExpireAt?.slice(0,10) }} + 冲刺到期:{{ u.sprintExpireAt?.slice(0,10) }} 加载更多 暂无面试记录 @@ -119,13 +152,17 @@ {{ r.title }} {{ r.userId?.phone || r.userId?.nickname || '--' }} + v{{ r.version }} {{ r.targetPosition }} - {{ r.createdAt?.slice(0,10) }} + + 创建:{{ r.createdAt?.slice(0,16).replace('T',' ') }} + 更新:{{ r.updatedAt?.slice(0,16).replace('T',' ') }} + 删除 @@ -148,20 +185,35 @@ - 订单号: {{ o.outTradeNo }} - 用户: {{ o.userPhone || o.userId.slice(-6) }} - - - ¥{{ (o.amount / 100).toFixed(1) }} - + {{ o.outTradeNo }} + {{ o.status === 'success' ? '已支付' : o.status === 'refunded' ? '已退款' : '待支付' }} - {{ o.createdAt ? o.createdAt.slice(0,16).replace('T',' ') : '--' }} - - 同步 - 退款 - 查询 - + + + {{ o.title || '--' }} + 用户: {{ o.userPhone || o.userId?.slice(-6) }} + + + ¥{{ (o.amount / 100).toFixed(1) }} + 类型:{{ o.type || '--' }} + 渠道:{{ o.channel || '--' }} + + + 创建:{{ o.createdAt?.slice(0,16).replace('T',' ') }} + 支付:{{ o.paidAt?.slice(0,16).replace('T',' ') }} + + + 微信单号:{{ o.wxTransactionId }} + + + 退款:¥{{ (o.refundAmount/100).toFixed(1) }} {{ o.refundedAt?.slice(0,16).replace('T',' ') }} + 原因:{{ o.refundReason }} + + + 同步 + 退款 + 查询 加载更多 @@ -283,13 +335,19 @@ 加载中... @@ -299,13 +357,17 @@ @@ -438,6 +502,7 @@ {{ searchResult.phone || '--' }} {{ searchResult.nickname || '--' }} + {{ searchResult.email }} 设为管理员 已是管理员 @@ -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; } - + +/* ─── 新增字段样式 ───── */ +.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; } + diff --git a/zhiyin-app/src/pages/user/user.vue b/zhiyin-app/src/pages/user/user.vue index 592ea3c..3384795 100644 --- a/zhiyin-app/src/pages/user/user.vue +++ b/zhiyin-app/src/pages/user/user.vue @@ -97,6 +97,13 @@ + + + ℹ️ 关于 @@ -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; }