gwy-exam/client/src/views/settings/SettingsView.vue

321 lines
9.6 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.

<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>