321 lines
9.6 KiB
Vue
321 lines
9.6 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } from 'vue'
|
||
import { profileApi, settingsApi } from '../../api'
|
||
import { useRequest } from '../../composables/useRequest'
|
||
import { useResponsive } from '../../composables/useResponsive'
|
||
import { useAppStore } from '../../stores/app'
|
||
import { useProfileStore } from '../../stores/profile'
|
||
import AppCard from '../../components/base/AppCard.vue'
|
||
import AppButton from '../../components/base/AppButton.vue'
|
||
import AppBadge from '../../components/base/AppBadge.vue'
|
||
import AppIcon from '../../components/base/AppIcon.vue'
|
||
import AppPageHeader from '../../components/base/AppPageHeader.vue'
|
||
import AppLoading from '../../components/feedback/AppLoading.vue'
|
||
import AppError from '../../components/feedback/AppError.vue'
|
||
|
||
const { isMobile } = useResponsive()
|
||
const app = useAppStore()
|
||
const profile = useProfileStore()
|
||
|
||
const settings = useRequest(() => settingsApi.get())
|
||
const profileReq = useRequest(() => profileApi.get(), false)
|
||
|
||
onMounted(() => {
|
||
if (!profileReq.data.value) profileReq.refresh()
|
||
})
|
||
|
||
// 表单镜像(挂载后从接口数据初始化)
|
||
const form = ref({
|
||
targetScore: 72,
|
||
examDate: '2026-11-30',
|
||
reminderTime: '19:30',
|
||
preferredDuration: 15,
|
||
preferredDifficulty: '中等',
|
||
learningReminder: true,
|
||
mockPush: false
|
||
})
|
||
const saving = ref(false)
|
||
|
||
watch([settings.data, profileReq.data], () => {
|
||
const s = settings.data.value
|
||
const p = profileReq.data.value
|
||
if (!s || !p) return
|
||
form.value = {
|
||
targetScore: p.targetScore,
|
||
examDate: p.examDate,
|
||
reminderTime: s.reminderTime,
|
||
preferredDuration: s.preferredDuration,
|
||
preferredDifficulty: s.preferredDifficulty ?? '中等',
|
||
learningReminder: s.learningReminder ?? true,
|
||
mockPush: s.mockPush ?? false
|
||
}
|
||
}, { immediate: true })
|
||
|
||
const aiConfigured = computed(() => settings.data.value?.aiConfigured ?? false)
|
||
|
||
async function save() {
|
||
saving.value = true
|
||
try {
|
||
// 保存设置(刷新后保留)
|
||
const nextSettings = await settingsApi.patch({
|
||
reminderTime: form.value.reminderTime,
|
||
preferredDuration: form.value.preferredDuration as 5 | 10 | 15,
|
||
preferredDifficulty: form.value.preferredDifficulty as '简单' | '中等' | '困难',
|
||
learningReminder: form.value.learningReminder,
|
||
mockPush: form.value.mockPush
|
||
})
|
||
// 保存档案(目标分 / 考试日期)
|
||
await profileApi.patch({
|
||
targetScore: Number(form.value.targetScore),
|
||
examDate: form.value.examDate
|
||
})
|
||
// 同步刷新 store 与接口
|
||
settings.data.value = nextSettings
|
||
profile.fetch()
|
||
profileReq.refresh()
|
||
app.toast('设置已保存', 'success')
|
||
} catch (err) {
|
||
app.toast(err instanceof Error ? err.message : '保存失败', 'error')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="settings">
|
||
<AppLoading v-if="settings.loading.value" fullscreen />
|
||
<AppError
|
||
v-else-if="settings.error.value"
|
||
fullscreen
|
||
title="设置加载失败"
|
||
:message="settings.error.value"
|
||
retry
|
||
@retry="settings.refresh"
|
||
/>
|
||
|
||
<template v-else-if="settings.data.value">
|
||
<!-- 页头 -->
|
||
<AppPageHeader title="设置" subtitle="调整偏好、通知与数据策略" stack-mobile sticky-mobile>
|
||
<AppButton variant="primary" :disabled="saving" @click="save">
|
||
<AppIcon name="check" :size="16" />{{ saving ? '保存中…' : '保存设置' }}
|
||
</AppButton>
|
||
</AppPageHeader>
|
||
|
||
<!-- 刷题设置 -->
|
||
<AppCard title="刷题设置" icon="pen">
|
||
<div class="setting-row">
|
||
<span class="setting-row__label">每日提醒时间</span>
|
||
<input v-model="form.reminderTime" class="setting-row__time" type="time" />
|
||
</div>
|
||
<div class="setting-row">
|
||
<span class="setting-row__label">题库难度偏好</span>
|
||
<select v-model="form.preferredDifficulty" class="setting-row__select">
|
||
<option value="简单">简单</option>
|
||
<option value="中等">中等</option>
|
||
<option value="困难">困难</option>
|
||
</select>
|
||
</div>
|
||
<div class="setting-row">
|
||
<span class="setting-row__label">默认答题时长</span>
|
||
<select v-model="form.preferredDuration" class="setting-row__select">
|
||
<option :value="5">5 分钟</option>
|
||
<option :value="10">10 分钟</option>
|
||
<option :value="15">15 分钟</option>
|
||
</select>
|
||
</div>
|
||
</AppCard>
|
||
|
||
<!-- 调查偏好 -->
|
||
<AppCard title="偏好设置" icon="target">
|
||
<div class="setting-row">
|
||
<span class="setting-row__label">目标分数</span>
|
||
<div class="setting-row__control">
|
||
<input v-model.number="form.targetScore" class="setting-row__input" type="number" min="0" max="100" />
|
||
<span class="setting-row__unit">分</span>
|
||
</div>
|
||
</div>
|
||
<div class="setting-row">
|
||
<span class="setting-row__label">考试日期</span>
|
||
<input v-model="form.examDate" class="setting-row__date" type="date" />
|
||
</div>
|
||
</AppCard>
|
||
|
||
<!-- 通知 -->
|
||
<AppCard title="通知" icon="inbox">
|
||
<div class="setting-row">
|
||
<span class="setting-row__label">学习提醒</span>
|
||
<AppButton
|
||
class="toggle"
|
||
:class="{ 'is-on': form.learningReminder }"
|
||
variant="ghost"
|
||
size="md"
|
||
role="switch"
|
||
:aria-checked="form.learningReminder"
|
||
@click="form.learningReminder = !form.learningReminder"
|
||
>
|
||
<span class="toggle__knob" />
|
||
</AppButton>
|
||
</div>
|
||
<div class="setting-row">
|
||
<span class="setting-row__label">模考成绩推送</span>
|
||
<AppButton
|
||
class="toggle"
|
||
:class="{ 'is-on': form.mockPush }"
|
||
variant="ghost"
|
||
size="md"
|
||
role="switch"
|
||
:aria-checked="form.mockPush"
|
||
@click="form.mockPush = !form.mockPush"
|
||
>
|
||
<span class="toggle__knob" />
|
||
</AppButton>
|
||
</div>
|
||
</AppCard>
|
||
|
||
<!-- AI 配置状态 -->
|
||
<AppCard title="AI 讲解" icon="settings">
|
||
<div class="setting-row">
|
||
<span class="setting-row__label">AI 讲解状态</span>
|
||
<AppBadge :variant="aiConfigured ? 'success' : 'warning'">
|
||
{{ aiConfigured ? '已配置' : '未配置' }}
|
||
</AppBadge>
|
||
</div>
|
||
<p class="setting-hint">
|
||
{{ aiConfigured
|
||
? 'AI 讲解已就绪,可在刷题结果页发起更深入的题目解析。'
|
||
: '在服务端环境变量中配置 AI_BASE_URL 与 AI_API_KEY 后即可启用 AI 讲解(Token 仅存于后端)。' }}
|
||
</p>
|
||
</AppCard>
|
||
|
||
<!-- 关于与版本 -->
|
||
<AppCard title="关于与版本" icon="book">
|
||
<div class="about-row">
|
||
<div class="about-row__body">
|
||
<p class="about-row__title">备考通 v1.0.0</p>
|
||
<p class="about-row__sub">个人备考数据中枢 · 数据归你所有</p>
|
||
</div>
|
||
<span class="about-row__badge">更新日志</span>
|
||
</div>
|
||
</AppCard>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.settings {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-5);
|
||
}
|
||
|
||
.setting-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--space-4);
|
||
padding: var(--space-4) 0;
|
||
}
|
||
.setting-row:not(:last-child) {
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
}
|
||
.setting-row__label {
|
||
font-size: var(--fs-subtitle);
|
||
color: var(--text-primary);
|
||
}
|
||
.setting-row__control {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
}
|
||
.setting-row__input,
|
||
.setting-row__select,
|
||
.setting-row__time,
|
||
.setting-row__date {
|
||
height: 36px;
|
||
padding: 0 var(--space-2);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--bg-card);
|
||
color: var(--text-primary);
|
||
font-size: var(--fs-subtitle);
|
||
}
|
||
.setting-row__input {
|
||
width: 72px;
|
||
text-align: right;
|
||
}
|
||
.setting-row__unit {
|
||
font-size: var(--fs-label);
|
||
color: var(--text-secondary);
|
||
}
|
||
.setting-row__select {
|
||
min-width: 96px;
|
||
}
|
||
.setting-hint {
|
||
margin: var(--space-2) 0 0;
|
||
font-size: var(--fs-label);
|
||
color: var(--text-secondary);
|
||
line-height: var(--lh-subtitle);
|
||
}
|
||
|
||
/* 开关(覆盖 AppButton 基样式,还原 switch 形态) */
|
||
.toggle {
|
||
display: grid !important;
|
||
place-items: center;
|
||
width: 44px !important;
|
||
height: 24px !important;
|
||
padding: 0 !important;
|
||
border: none !important;
|
||
border-radius: var(--radius-pill) !important;
|
||
background: var(--bg-soft) !important;
|
||
color: var(--text-secondary) !important;
|
||
gap: 0 !important;
|
||
flex: none;
|
||
cursor: pointer;
|
||
transition: background var(--motion-hover-fast) var(--ease-default);
|
||
}
|
||
.toggle:hover {
|
||
background: var(--bg-soft) !important;
|
||
}
|
||
.toggle.is-on {
|
||
background: var(--primary) !important;
|
||
}
|
||
.toggle__knob {
|
||
width: 20px;
|
||
height: 20px;
|
||
border-radius: 50%;
|
||
background: var(--bg-card);
|
||
box-shadow: var(--shadow-level-1);
|
||
transition: transform var(--motion-switch) var(--ease-default);
|
||
transform: translateX(-10px);
|
||
}
|
||
.toggle.is-on .toggle__knob {
|
||
transform: translateX(10px);
|
||
}
|
||
|
||
/* 关于 */
|
||
.about-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--space-4);
|
||
}
|
||
.about-row__title {
|
||
margin: 0;
|
||
font-size: var(--fs-subtitle);
|
||
font-weight: var(--fw-card-title);
|
||
color: var(--text-primary);
|
||
}
|
||
.about-row__sub {
|
||
margin: 2px 0 0;
|
||
font-size: var(--fs-label);
|
||
color: var(--text-secondary);
|
||
}
|
||
.about-row__badge {
|
||
flex: none;
|
||
color: var(--primary);
|
||
font-size: var(--fs-label);
|
||
font-weight: var(--fw-button);
|
||
}
|
||
</style>
|