Files
smt/src/pages/supervisor/index.vue
T

741 lines
17 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="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">已绑定 {{ supervisorItems.length }}/3</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">
{{ supervisorItems.length >= 3 ? '监督人已满(最多 3 人),你可以先解除一个再邀请' : '生成一个邀请口令,发给你信得过的人' }}
</text>
<button class="btn" :disabled="inviteLoading || supervisorItems.length >= 3" @tap="generateInvite">
{{ supervisorItems.length >= 3 ? '已达上限' : (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 class="row-actions">
<button class="mini-btn" @tap.stop="confirmRevoke(item)">解除监督</button>
</view>
</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="card">
<view class="card-head">
<text class="card-title">提醒设置</text>
<text class="card-meta">默认关闭</text>
</view>
<view class="settings">
<view class="setting-row">
<text class="setting-label">启用提醒</text>
<switch :checked="reminderEnabled" @change="onToggleReminder" />
</view>
<view class="setting-row">
<text class="setting-label">提醒时间</text>
<view class="setting-control">
<button class="mini-btn mini-btn-neutral" :disabled="!reminderEnabled" @tap="pickNotifyTime">
{{ reminderNotifyTime || '21:00' }}
</button>
</view>
</view>
<view class="setting-row">
<text class="setting-label">每日上限</text>
<view class="setting-control">
<input
class="num-input"
type="number"
:value="String(reminderMaxPerDay)"
:disabled="!reminderEnabled"
placeholder="1"
@input="onMaxPerDayInput"
/>
<text class="setting-hint">每个监督人每天最多 N 建议 1</text>
</view>
</view>
<view class="setting-actions">
<button class="btn btn-ghost" :disabled="savingSettings" @tap="reloadReminderSettings">重载</button>
<button class="btn" :disabled="savingSettings" @tap="saveReminderSettings">
{{ savingSettings ? '保存中...' : '保存设置' }}
</button>
</view>
<text class="settings-note">提示当前版本后端仅记录提醒日志stub尚未接入真实订阅消息发送</text>
</view>
</view>
<view class="card">
<view class="card-head">
<text class="card-title">提醒测试监督人</text>
<text class="card-meta">仅写日志</text>
</view>
<view class="settings">
<text class="settings-note">你作为监督人时可手动触发一次提醒流程用于联调频控与条件判断</text>
<button class="btn" :disabled="runningReminders" @tap="runRemindersNow">
{{ runningReminders ? '触发中...' : '手动触发提醒' }}
</button>
<text v-if="lastRunText" class="run-result">{{ lastRunText }}</text>
</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 { useUserStore } from '@/stores/user'
import {
createSupervisorInvite,
getSupervisorOverview,
getSupervisorStatus,
revokeSupervisorBinding,
getSupervisorReminderSettings,
updateSupervisorReminderSettings,
runSupervisorReminders
} from '@/api/smoke'
const { waitForLogin } = useLogin()
const userStore = useUserStore()
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'
const savingSettings = ref(false)
const reminderEnabled = ref(false)
const reminderNotifyTime = ref('21:00')
const reminderMaxPerDay = ref(1)
const runningReminders = ref(false)
const lastRunText = ref('')
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' })
}
function confirmRevoke(item) {
const ownerUID = item?.owner?.user_id
if (!ownerUID) return
uni.showModal({
title: '解除监督',
content: '解除后你将无法再查看对方的戒烟概览。确定要解除吗?',
confirmText: '解除',
confirmColor: '#b91c1c',
success: async (res) => {
if (!res.confirm) return
await doRevoke(ownerUID)
}
})
}
async function doRevoke(ownerUID) {
try {
loading.value = true
const myUID = Number(userStore.user?.id)
if (!myUID) {
uni.showToast({ title: '登录信息缺失', icon: 'none' })
return
}
await revokeSupervisorBinding(ownerUID, myUID)
uni.showToast({ title: '已解除', icon: 'success' })
await refreshAll()
} catch (e) {
console.error('revoke error:', e)
uni.showToast({ title: '解除失败', icon: 'none' })
} finally {
loading.value = false
}
}
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, reminder] = await Promise.all([
getSupervisorOverview(),
getSupervisorStatus(),
getSupervisorReminderSettings()
])
overviewItems.value = overview.data?.items || []
supervisorItems.value = status.data?.items || []
applyReminderSettings(reminder.data)
} catch (e) {
console.error('refreshAll error:', e)
uni.showToast({ title: '刷新失败', icon: 'none' })
} finally {
loading.value = false
}
}
function applyReminderSettings(data) {
reminderEnabled.value = !!data?.enabled
reminderNotifyTime.value = data?.notify_time || '21:00'
const maxN = Number(data?.max_per_day)
reminderMaxPerDay.value = Number.isNaN(maxN) ? 1 : Math.min(Math.max(maxN, 0), 10)
}
async function reloadReminderSettings() {
try {
const res = await getSupervisorReminderSettings()
applyReminderSettings(res.data)
uni.showToast({ title: '已重载', icon: 'success' })
} catch (e) {
console.error('reloadReminderSettings error:', e)
uni.showToast({ title: '重载失败', icon: 'none' })
}
}
function onToggleReminder(e) {
reminderEnabled.value = !!e?.detail?.value
}
function pickNotifyTime() {
if (!reminderEnabled.value) return
uni.showModal({
title: '设置提醒时间',
content: '当前版本请手动输入 HH:MM(例如 21:00)。',
editable: true,
placeholderText: reminderNotifyTime.value || '21:00',
success: (res) => {
if (!res.confirm) return
const v = String(res.content || '').trim()
if (!/^\d{2}:\d{2}$/.test(v)) {
uni.showToast({ title: '格式应为 HH:MM', icon: 'none' })
return
}
reminderNotifyTime.value = v
}
})
}
function onMaxPerDayInput(e) {
const v = Number(e?.detail?.value)
if (Number.isNaN(v)) {
reminderMaxPerDay.value = 1
return
}
reminderMaxPerDay.value = Math.min(Math.max(Math.floor(v), 0), 10)
}
async function saveReminderSettings() {
if (savingSettings.value) return
savingSettings.value = true
try {
const payload = {
enabled: reminderEnabled.value,
notify_time: reminderNotifyTime.value,
max_per_day: reminderMaxPerDay.value
}
const res = await updateSupervisorReminderSettings(payload)
applyReminderSettings(res.data)
uni.showToast({ title: '已保存', icon: 'success' })
} catch (e) {
console.error('saveReminderSettings error:', e)
uni.showToast({ title: '保存失败', icon: 'none' })
} finally {
savingSettings.value = false
}
}
async function runRemindersNow() {
if (runningReminders.value) return
runningReminders.value = true
lastRunText.value = ''
try {
const res = await runSupervisorReminders()
const created = res.data?.created ?? 0
const skipped = res.data?.skipped ?? 0
lastRunText.value = `本次触发:写入 ${created} 条,跳过 ${skipped}`
uni.showToast({ title: '已触发', icon: 'success' })
} catch (e) {
console.error('runRemindersNow error:', e)
uni.showToast({ title: '触发失败', icon: 'none' })
} finally {
runningReminders.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;
}
.row-actions {
margin-top: 12rpx;
display: flex;
justify-content: flex-end;
}
.mini-btn {
height: 56rpx;
line-height: 56rpx;
padding: 0 18rpx;
border-radius: 14rpx;
background: #ffffff;
border: 1rpx solid rgba(185, 28, 28, 0.28);
color: #b91c1c;
font-size: 22rpx;
font-weight: 700;
}
.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);
}
.mini-btn-neutral {
border-color: rgba(15, 23, 42, 0.12);
color: #334155;
}
.settings {
margin-top: 18rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.setting-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18rpx;
padding: 14rpx 12rpx;
border-radius: 18rpx;
background: rgba(241, 245, 249, 0.45);
border: 1rpx solid rgba(15, 23, 42, 0.04);
}
.setting-label {
font-size: 24rpx;
font-weight: 800;
color: #0f172a;
padding-top: 6rpx;
}
.setting-control {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10rpx;
}
.num-input {
width: 200rpx;
height: 64rpx;
padding: 0 14rpx;
border-radius: 14rpx;
background: rgba(255, 255, 255, 0.92);
border: 1rpx solid rgba(15, 23, 42, 0.08);
font-size: 26rpx;
text-align: right;
}
.setting-hint {
font-size: 20rpx;
line-height: 1.5;
color: #64748b;
text-align: right;
}
.setting-actions {
display: flex;
gap: 14rpx;
}
.settings-note {
font-size: 22rpx;
line-height: 1.6;
color: #64748b;
}
.run-result {
font-size: 22rpx;
color: #0f766e;
font-weight: 700;
}
</style>