feat(supervisor): add invite and bind pages

This commit is contained in:
nepiedg
2026-04-16 11:45:57 +08:00
parent cbe1fdb035
commit aef9fa9eac
5 changed files with 606 additions and 0 deletions
+16
View File
@@ -132,3 +132,19 @@ export function updateRewardGoal(id, data) {
return request.request({ url: `/reward-goals/${id}`, method: 'PUT', data, baseUrl: BASE_URL_V2 })
}
// 监督人机制(Phase 3
export function createSupervisorInvite(days = 7) {
return request.request({ url: '/supervisor/invites', method: 'POST', data: { days }, baseUrl: BASE_URL_V2 })
}
export function bindSupervisorInvite(token) {
return request.request({ url: '/supervisor/bind', method: 'POST', data: { token }, baseUrl: BASE_URL_V2 })
}
export function getSupervisorOverview() {
return request.request({ url: '/supervisor/overview', method: 'GET', baseUrl: BASE_URL_V2 })
}
export function getSupervisorStatus() {
return request.request({ url: '/supervisor/status', method: 'GET', baseUrl: BASE_URL_V2 })
}
+14
View File
@@ -96,6 +96,20 @@
"navigationStyle": "default",
"navigationBarTitleText": "梦想清单"
}
},
{
"path": "pages/supervisor/index",
"style": {
"navigationBarTitleText": "监督人",
"navigationStyle": "default"
}
},
{
"path": "pages/supervisor/bind",
"style": {
"navigationBarTitleText": "绑定监督",
"navigationStyle": "default"
}
}
],
"globalStyle": {
+17
View File
@@ -59,6 +59,19 @@
<view class="menu-divider"></view>
<view class="menu-item" @tap="goSupervisor">
<view class="menu-icon menu-icon-accent">
<text class="menu-glyph"></text>
</view>
<view class="menu-content">
<text class="menu-label">监督人</text>
<text class="menu-desc">邀请朋友监督你或查看你监督的人</text>
</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-divider"></view>
<view class="menu-item" @tap="goNSTI">
<view class="menu-icon menu-icon-nsti">
<text class="menu-glyph"></text>
@@ -286,6 +299,10 @@ function goNSTI() {
uni.navigateTo({ url: '/pages/nsti/index' })
}
function goSupervisor() {
uni.navigateTo({ url: '/pages/supervisor/index' })
}
function clearCache() {
uni.showModal({
title: '清除缓存',
+121
View File
@@ -0,0 +1,121 @@
<template>
<view class="page">
<view class="card">
<text class="title">绑定监督</text>
<text class="desc">输入朋友发给你的邀请口令即可查看对方的戒烟概览</text>
<input class="input" v-model="token" placeholder="请输入邀请口令" />
<button class="btn" :disabled="loading || !token" @tap="doBind">
{{ loading ? '绑定中...' : '确认绑定' }}
</button>
<button class="btn btn-ghost" @tap="gotoSupervisorHome">返回监督人页面</button>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useLogin } from '@/hooks/useLogin'
import { bindSupervisorInvite } from '@/api/smoke'
const { waitForLogin } = useLogin()
const token = ref('')
const loading = ref(false)
function gotoSupervisorHome() {
uni.navigateTo({ url: '/pages/supervisor/index' })
}
async function doBind() {
if (loading.value) return
loading.value = true
try {
await bindSupervisorInvite(token.value.trim())
uni.showToast({ title: '绑定成功', icon: 'success' })
setTimeout(() => {
uni.redirectTo({ url: '/pages/supervisor/index' })
}, 600)
} catch (e) {
console.error('bind error:', e)
uni.showToast({ title: e?.message || '绑定失败', icon: 'none' })
} finally {
loading.value = false
}
}
onLoad(async (query) => {
await waitForLogin()
if (query?.token) {
token.value = String(query.token)
}
})
</script>
<style scoped>
.page {
min-height: 100vh;
padding: 40rpx 28rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #eef7f3 0%, #fbfdff 100%);
}
.card {
background: rgba(255, 255, 255, 0.94);
border-radius: 26rpx;
border: 1rpx solid rgba(15, 23, 42, 0.06);
padding: 28rpx 24rpx;
box-shadow: 0 12rpx 28rpx rgba(15, 23, 42, 0.06);
}
.title {
display: block;
font-size: 38rpx;
font-weight: 900;
color: #0f172a;
}
.desc {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.6;
color: #64748b;
}
.input {
margin-top: 22rpx;
height: 84rpx;
padding: 0 20rpx;
border-radius: 18rpx;
background: rgba(241, 245, 249, 0.9);
border: 1rpx solid rgba(15, 23, 42, 0.08);
font-size: 28rpx;
color: #0f172a;
}
.btn {
margin-top: 18rpx;
height: 78rpx;
line-height: 78rpx;
border-radius: 18rpx;
background: linear-gradient(180deg, #1aa37a 0%, #0f766e 100%);
color: #ffffff;
font-size: 26rpx;
font-weight: 700;
}
.btn[disabled] {
opacity: 0.6;
}
.btn-ghost {
background: #ffffff;
color: #0f766e;
border: 1rpx solid rgba(15, 118, 110, 0.25);
}
</style>
+438
View File
@@ -0,0 +1,438 @@
<template>
<view class="page">
<view class="header">
<text class="title">监督人机制</text>
<text class="subtitle">邀请朋友监督你的戒烟旅程或者你来监督别人</text>
</view>
<view class="card">
<view class="card-head">
<text class="card-title">邀请监督人</text>
<text class="card-meta">有效期默认 7 </text>
</view>
<view v-if="inviteToken" class="invite-box">
<text class="invite-label">邀请口令</text>
<text class="invite-token">{{ inviteToken }}</text>
<text class="invite-hint">对方打开小程序后进入绑定监督页面输入口令即可</text>
<view class="invite-actions">
<button class="btn btn-ghost" @tap="copyInviteToken">复制口令</button>
<button class="btn" @tap="copyInvitePath">复制链接</button>
</view>
<text v-if="inviteExpireAt" class="invite-expire">过期时间{{ formatDateTime(inviteExpireAt) }}</text>
</view>
<view v-else class="invite-empty">
<text class="invite-empty-text">生成一个邀请口令发给你信得过的人</text>
<button class="btn" :disabled="inviteLoading" @tap="generateInvite">
{{ inviteLoading ? '生成中...' : '生成邀请口令' }}
</button>
</view>
</view>
<view class="card">
<view class="card-head">
<text class="card-title">我监督的人</text>
<text class="card-meta">{{ overviewItems.length }} </text>
</view>
<view v-if="overviewItems.length === 0" class="empty">
<text class="empty-text">还没有绑定监督关系</text>
<text class="empty-hint">收到口令后可去绑定监督页面完成绑定</text>
<button class="btn btn-ghost" @tap="gotoBindPage">去绑定监督</button>
</view>
<view v-else class="list">
<view v-for="item in overviewItems" :key="item.owner?.user_id" class="row">
<image class="avatar" :src="item.owner?.avatar_url || defaultAvatar" mode="aspectFill"></image>
<view class="row-main">
<text class="name">{{ item.owner?.nickname || `用户 ${item.owner?.user_id}` }}</text>
<view class="meta">
<text class="pill">连续 {{ item.home?.summary?.current_streak_days || 0 }} </text>
<text class="pill">HP {{ item.home?.summary?.hp_current ?? '--' }}</text>
<text class="pill" :class="hpDeltaClass(item.home?.summary?.hp_change_today)">
{{ hpDeltaText(item.home?.summary?.hp_change_today) }}
</text>
</view>
<text class="status">今日{{ statusText(item.home?.daily_status?.status) }}</text>
</view>
</view>
</view>
</view>
<view class="card">
<view class="card-head">
<text class="card-title">监督我的人</text>
<text class="card-meta">{{ supervisorItems.length }} </text>
</view>
<view v-if="supervisorItems.length === 0" class="empty">
<text class="empty-text">还没有人监督你</text>
<text class="empty-hint">你可以先生成邀请口令发送给朋友</text>
</view>
<view v-else class="list">
<view v-for="u in supervisorItems" :key="u.user_id" class="row">
<image class="avatar" :src="u.avatar_url || defaultAvatar" mode="aspectFill"></image>
<view class="row-main">
<text class="name">{{ u.nickname || `用户 ${u.user_id}` }}</text>
<text class="status">可查看你的戒烟概览</text>
</view>
</view>
</view>
</view>
<view class="footer">
<button class="btn btn-ghost" :disabled="loading" @tap="refreshAll">刷新</button>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useLogin } from '@/hooks/useLogin'
import { createSupervisorInvite, getSupervisorOverview, getSupervisorStatus } from '@/api/smoke'
const { waitForLogin } = useLogin()
const loading = ref(false)
const inviteLoading = ref(false)
const inviteToken = ref('')
const inviteExpireAt = ref('')
const overviewItems = ref([])
const supervisorItems = ref([])
const defaultAvatar = 'https://linghu-wmr.oss-cn-beijing.aliyuncs.com/smt/avatar.png'
function formatDateTime(value) {
if (!value) return '--'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return `${y}-${m}-${day} ${hh}:${mm}`
}
function statusText(status) {
if (status === 'checked_in') return '已打卡'
if (status === 'relapsed') return '已复吸'
if (status === 'pending') return '未打卡'
if (status === 'missed') return '缺失'
return status || '--'
}
function hpDeltaText(delta) {
const n = Number(delta)
if (Number.isNaN(n) || n === 0) return '今日 0'
return n > 0 ? `今日 +${n}` : `今日 ${n}`
}
function hpDeltaClass(delta) {
const n = Number(delta)
if (Number.isNaN(n) || n === 0) return 'pill-muted'
return n > 0 ? 'pill-up' : 'pill-down'
}
function copy(text) {
if (!text) return
uni.setClipboardData({
data: text,
success: () => uni.showToast({ title: '已复制', icon: 'success' })
})
}
function invitePath() {
if (!inviteToken.value) return ''
return `/pages/supervisor/bind?token=${inviteToken.value}`
}
function copyInviteToken() {
copy(inviteToken.value)
}
function copyInvitePath() {
copy(invitePath())
}
function gotoBindPage() {
uni.navigateTo({ url: '/pages/supervisor/bind' })
}
async function generateInvite() {
if (inviteLoading.value) return
inviteLoading.value = true
try {
const res = await createSupervisorInvite(7)
inviteToken.value = res.data?.token || ''
inviteExpireAt.value = res.data?.expire_at || ''
if (!inviteToken.value) {
uni.showToast({ title: '生成失败', icon: 'none' })
}
} catch (e) {
console.error('generateInvite error:', e)
uni.showToast({ title: '生成失败', icon: 'none' })
} finally {
inviteLoading.value = false
}
}
async function refreshAll() {
if (loading.value) return
loading.value = true
try {
const [overview, status] = await Promise.all([getSupervisorOverview(), getSupervisorStatus()])
overviewItems.value = overview.data?.items || []
supervisorItems.value = status.data?.items || []
} catch (e) {
console.error('refreshAll error:', e)
uni.showToast({ title: '刷新失败', icon: 'none' })
} finally {
loading.value = false
}
}
onMounted(() => {})
onShow(async () => {
await waitForLogin()
await refreshAll()
})
</script>
<style scoped>
.page {
min-height: 100vh;
padding: 28rpx 28rpx 40rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #eef7f3 0%, #f7faf8 40%, #fbfdff 100%);
}
.header {
padding: 8rpx 6rpx 18rpx;
}
.title {
display: block;
font-size: 40rpx;
font-weight: 800;
color: #0f172a;
letter-spacing: 0.5rpx;
}
.subtitle {
display: block;
margin-top: 10rpx;
font-size: 24rpx;
line-height: 1.6;
color: #64748b;
}
.card {
margin-top: 18rpx;
background: rgba(255, 255, 255, 0.92);
border-radius: 26rpx;
border: 1rpx solid rgba(15, 23, 42, 0.06);
padding: 22rpx 22rpx;
box-shadow: 0 10rpx 26rpx rgba(15, 23, 42, 0.05);
}
.card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16rpx;
}
.card-title {
font-size: 28rpx;
font-weight: 800;
color: #0f172a;
}
.card-meta {
font-size: 22rpx;
color: #94a3b8;
}
.invite-box {
margin-top: 18rpx;
}
.invite-label {
display: block;
font-size: 22rpx;
color: #64748b;
}
.invite-token {
display: block;
margin-top: 10rpx;
font-size: 36rpx;
font-weight: 900;
letter-spacing: 2rpx;
color: #0f766e;
font-family: 'DIN Alternate', -apple-system, sans-serif;
}
.invite-hint {
display: block;
margin-top: 10rpx;
font-size: 22rpx;
line-height: 1.6;
color: #475569;
}
.invite-actions {
display: flex;
gap: 14rpx;
margin-top: 16rpx;
}
.invite-expire {
display: block;
margin-top: 14rpx;
font-size: 22rpx;
color: #94a3b8;
}
.invite-empty {
margin-top: 18rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.invite-empty-text {
font-size: 24rpx;
line-height: 1.6;
color: #475569;
}
.empty {
margin-top: 18rpx;
padding: 16rpx 6rpx 6rpx;
}
.empty-text {
display: block;
font-size: 24rpx;
font-weight: 700;
color: #0f172a;
}
.empty-hint {
display: block;
margin-top: 10rpx;
font-size: 22rpx;
line-height: 1.6;
color: #64748b;
}
.list {
margin-top: 18rpx;
display: flex;
flex-direction: column;
gap: 14rpx;
}
.row {
display: flex;
gap: 16rpx;
align-items: flex-start;
padding: 16rpx;
border-radius: 20rpx;
background: rgba(241, 245, 249, 0.6);
border: 1rpx solid rgba(15, 23, 42, 0.04);
}
.avatar {
width: 74rpx;
height: 74rpx;
border-radius: 50%;
background: #e2e8f0;
flex-shrink: 0;
}
.row-main {
flex: 1;
min-width: 0;
}
.name {
display: block;
font-size: 28rpx;
font-weight: 800;
color: #0f172a;
}
.meta {
margin-top: 10rpx;
display: flex;
gap: 10rpx;
flex-wrap: wrap;
}
.pill {
padding: 8rpx 12rpx;
border-radius: 999rpx;
background: #ffffff;
border: 1rpx solid rgba(15, 23, 42, 0.06);
font-size: 20rpx;
color: #334155;
}
.pill-muted {
color: #64748b;
}
.pill-up {
color: #0f766e;
background: rgba(204, 251, 241, 0.6);
}
.pill-down {
color: #b91c1c;
background: rgba(254, 226, 226, 0.8);
}
.status {
display: block;
margin-top: 10rpx;
font-size: 22rpx;
color: #64748b;
}
.footer {
margin-top: 20rpx;
padding-bottom: 20rpx;
display: flex;
justify-content: center;
}
.btn {
height: 76rpx;
line-height: 76rpx;
padding: 0 22rpx;
border-radius: 18rpx;
background: linear-gradient(180deg, #1aa37a 0%, #0f766e 100%);
color: #ffffff;
font-size: 26rpx;
font-weight: 700;
}
.btn[disabled] {
opacity: 0.6;
}
.btn-ghost {
background: #ffffff;
color: #0f766e;
border: 1rpx solid rgba(15, 118, 110, 0.25);
}
</style>