Add word-book practice, iOS app shell, and fix embedded WebView blank screen.
Ship dual-track learning (daily accumulation vs textbook),沪教/商务词书 APIs and UI, native iOS wrapper with bundled H5, and production book import on deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"build:ios": "vite build --mode ios",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+94
-12
@@ -1,11 +1,28 @@
|
||||
import axios from 'axios'
|
||||
import { appNavigate, getRuntime, isAuthPath } from '../config/runtime'
|
||||
import { clearAuth, getToken } from '../utils/auth'
|
||||
|
||||
function normalizeApiBase(base: string): string {
|
||||
const trimmed = base.trim().replace(/\/+$/, '')
|
||||
return trimmed || '/api'
|
||||
}
|
||||
|
||||
let apiBaseURL = normalizeApiBase(getRuntime().apiBase)
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/api',
|
||||
baseURL: apiBaseURL,
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
export function setApiBaseURL(base: string) {
|
||||
apiBaseURL = normalizeApiBase(base)
|
||||
request.defaults.baseURL = apiBaseURL
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__wordloopSetApiBase = setApiBaseURL
|
||||
}
|
||||
|
||||
request.interceptors.request.use((config) => {
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
@@ -19,8 +36,8 @@ request.interceptors.response.use(
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
clearAuth()
|
||||
if (!window.location.pathname.includes('/login')) {
|
||||
window.location.href = '/login'
|
||||
if (!isAuthPath()) {
|
||||
appNavigate('/login')
|
||||
}
|
||||
}
|
||||
return Promise.reject(err)
|
||||
@@ -136,6 +153,7 @@ export interface TranslateResult {
|
||||
phonetic?: string
|
||||
example_en?: string
|
||||
example_cn?: string
|
||||
found: boolean
|
||||
}
|
||||
|
||||
export interface QuizOption {
|
||||
@@ -152,6 +170,8 @@ export interface QuizQuestion {
|
||||
phonetic?: string
|
||||
}
|
||||
|
||||
export type QuizTrack = 'accumulation' | 'book'
|
||||
|
||||
export interface QuizStats {
|
||||
total_words: number
|
||||
new_count: number
|
||||
@@ -164,6 +184,45 @@ export interface QuizStats {
|
||||
daily_target: number
|
||||
today_completed: number
|
||||
streak_days: number
|
||||
track?: QuizTrack
|
||||
book_id?: number | null
|
||||
}
|
||||
|
||||
export interface WordBook {
|
||||
id: number
|
||||
slug: string
|
||||
title: string
|
||||
description?: string | null
|
||||
level: string
|
||||
word_count: number
|
||||
unit_count: number
|
||||
sort_order: number
|
||||
daily_target: number
|
||||
master_required_count: number
|
||||
weak_wrong_threshold: number
|
||||
learn_mode: string
|
||||
}
|
||||
|
||||
export interface WordBookProgress {
|
||||
book_id: number
|
||||
total: number
|
||||
introduced: number
|
||||
mastered: number
|
||||
learning: number
|
||||
locked: number
|
||||
}
|
||||
|
||||
export interface BookPracticeSettings {
|
||||
book_id: number
|
||||
daily_target: number
|
||||
master_required_count: number
|
||||
weak_wrong_threshold: number
|
||||
}
|
||||
|
||||
export interface ActiveBookState {
|
||||
book: WordBook | null
|
||||
progress: WordBookProgress | null
|
||||
settings?: BookPracticeSettings | null
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
@@ -258,18 +317,39 @@ export const api = {
|
||||
updateMe: (data: UserProfileUpdate) => request.patch<UserProfile>('/auth/me', data),
|
||||
translate: (text: string) => request.post<TranslateResult>('/translate', { text }),
|
||||
createWord: (data: Partial<Word>) => request.post<Word>('/words', data),
|
||||
listWords: (status?: string) =>
|
||||
request.get<Word[]>('/words', { params: status ? { status } : {} }),
|
||||
listWords: (status?: string, bookId?: number) =>
|
||||
request.get<Word[]>('/words', {
|
||||
params: {
|
||||
...(status ? { status } : {}),
|
||||
...(bookId !== undefined ? { book_id: bookId } : {}),
|
||||
},
|
||||
}),
|
||||
getWord: (id: number) => request.get<Word>(`/words/${id}`),
|
||||
memoryViz: () => request.get<MemoryVisualization>('/words/memory-viz'),
|
||||
memoryViz: (bookId = 0) =>
|
||||
request.get<MemoryVisualization>('/words/memory-viz', { params: { book_id: bookId } }),
|
||||
listBooks: () => request.get<WordBook[]>('/books'),
|
||||
getActiveBook: () => request.get<ActiveBookState>('/books/active'),
|
||||
setActiveBook: (bookId: number) =>
|
||||
request.post<ActiveBookState>('/books/active', { book_id: bookId }),
|
||||
getBookSettings: () => request.get<BookPracticeSettings>('/books/settings'),
|
||||
updateBookSettings: (data: {
|
||||
book_id?: number
|
||||
daily_target?: number
|
||||
master_required_count?: number
|
||||
weak_wrong_threshold?: number
|
||||
}) => request.patch<ActiveBookState>('/books/settings', data),
|
||||
wordMemory: (id: number) => request.get<WordMemoryDetail>(`/words/${id}/memory`),
|
||||
wordMemoryModel: (id: number) =>
|
||||
request.get<MemoryTransformerPredict>(`/words/${id}/memory-model`),
|
||||
deleteWord: (id: number) => request.delete(`/words/${id}`),
|
||||
dailyQuiz: () =>
|
||||
request.get<{ questions: QuizQuestion[]; total: number }>('/quiz/daily'),
|
||||
spellQuiz: () =>
|
||||
request.get<{ questions: QuizQuestion[]; total: number }>('/quiz/spell'),
|
||||
dailyQuiz: (track: QuizTrack = 'accumulation') =>
|
||||
request.get<{ questions: QuizQuestion[]; total: number; track: QuizTrack }>('/quiz/daily', {
|
||||
params: { track },
|
||||
}),
|
||||
spellQuiz: (track: QuizTrack = 'accumulation') =>
|
||||
request.get<{ questions: QuizQuestion[]; total: number; track: QuizTrack }>('/quiz/spell', {
|
||||
params: { track },
|
||||
}),
|
||||
submitAnswer: (data: {
|
||||
word_id: number
|
||||
question_type: string
|
||||
@@ -277,10 +357,12 @@ export const api = {
|
||||
correct_answer: string
|
||||
duration_seconds?: number
|
||||
}) => request.post('/quiz/answer', data),
|
||||
quizStats: () => request.get<QuizStats>('/quiz/stats'),
|
||||
quizStats: (track: QuizTrack = 'accumulation') =>
|
||||
request.get<QuizStats>('/quiz/stats', { params: { track } }),
|
||||
getSettings: () => request.get<Settings>('/settings'),
|
||||
updateSettings: (data: Partial<Settings>) => request.patch<Settings>('/settings', data),
|
||||
coachSession: () => request.get<CoachSessionResponse>('/coach/session'),
|
||||
coachSession: (track: QuizTrack = 'accumulation') =>
|
||||
request.get<CoachSessionResponse>('/coach/session', { params: { track } }),
|
||||
coachTurn: (data: {
|
||||
word_id: number
|
||||
stage?: string
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useDraggableFab } from '../composables/useDraggableFab'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const open = ref(false)
|
||||
const { wrapStyle, dragging, onFabPointerDown, consumeDragClick } = useDraggableFab(
|
||||
'wordloop-fab-train',
|
||||
() => ({
|
||||
x: window.innerWidth - 16 - 56,
|
||||
y: window.innerHeight - 64 - 56 - 16,
|
||||
})
|
||||
)
|
||||
|
||||
const trainPaths = ['/quiz', '/spell', '/coach', '/graph-practice']
|
||||
|
||||
@@ -19,6 +27,7 @@ const items = [
|
||||
]
|
||||
|
||||
function toggle() {
|
||||
if (consumeDragClick()) return
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
@@ -36,7 +45,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="train-fab-wrap">
|
||||
<div class="train-fab-wrap" :style="wrapStyle">
|
||||
<div v-if="open" class="train-fab-backdrop" @click="open = false" />
|
||||
|
||||
<transition name="train-menu">
|
||||
@@ -58,9 +67,10 @@ watch(
|
||||
<button
|
||||
type="button"
|
||||
class="train-fab-btn"
|
||||
:class="{ active: isTrainRoute, open }"
|
||||
:class="{ active: isTrainRoute, open, dragging }"
|
||||
aria-label="训练菜单"
|
||||
:aria-expanded="open"
|
||||
@pointerdown="onFabPointerDown"
|
||||
@click="toggle"
|
||||
>
|
||||
<span class="fab-icon">{{ open ? '×' : '✏️' }}</span>
|
||||
@@ -72,8 +82,6 @@ watch(
|
||||
<style scoped>
|
||||
.train-fab-wrap {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: calc(64px + env(safe-area-inset-bottom));
|
||||
z-index: 110;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -137,14 +145,21 @@ watch(
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(79, 110, 247, 0.45);
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.train-fab-btn:hover {
|
||||
.train-fab-btn:hover:not(.dragging) {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.train-fab-btn.dragging {
|
||||
cursor: grabbing;
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 6px 20px rgba(79, 110, 247, 0.55);
|
||||
}
|
||||
|
||||
.train-fab-btn.active {
|
||||
box-shadow: 0 4px 20px rgba(79, 110, 247, 0.55);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import TranslateResultPanel from './TranslateResultPanel.vue'
|
||||
import { api, type TranslateResult } from '../api/request'
|
||||
import { useDraggableFab } from '../composables/useDraggableFab'
|
||||
import { useTranslateFab } from '../composables/useTranslateFab'
|
||||
|
||||
const route = useRoute()
|
||||
const { open, toggleFab, closeFab } = useTranslateFab()
|
||||
const { wrapStyle, dragging, onFabPointerDown, consumeDragClick } = useDraggableFab(
|
||||
'wordloop-fab-translate',
|
||||
() => ({
|
||||
x: 16,
|
||||
y: Math.round((window.innerHeight - 64) / 2 - 28),
|
||||
})
|
||||
)
|
||||
|
||||
const input = ref('')
|
||||
const result = ref<TranslateResult | null>(null)
|
||||
@@ -15,8 +24,6 @@ const loading = ref(false)
|
||||
const adding = ref(false)
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const langLabel = (s: string) => (s === 'zh' ? '中文 → 英文' : '英文 → 中文')
|
||||
|
||||
function resetState() {
|
||||
input.value = ''
|
||||
result.value = null
|
||||
@@ -26,6 +33,7 @@ function resetState() {
|
||||
}
|
||||
|
||||
function handleToggle() {
|
||||
if (consumeDragClick()) return
|
||||
toggleFab()
|
||||
}
|
||||
|
||||
@@ -48,13 +56,13 @@ async function handleTranslate() {
|
||||
}
|
||||
|
||||
async function addToLibrary() {
|
||||
if (!result.value || adding.value) return
|
||||
if (!result.value || adding.value || !result.value.target_text.trim()) return
|
||||
adding.value = true
|
||||
message.value = ''
|
||||
try {
|
||||
await api.createWord({
|
||||
source_text: result.value.source_text,
|
||||
target_text: result.value.target_text,
|
||||
target_text: result.value.target_text.trim(),
|
||||
source_lang: result.value.source_lang,
|
||||
target_lang: result.value.target_lang,
|
||||
phonetic: result.value.phonetic,
|
||||
@@ -101,7 +109,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="translate-fab-wrap">
|
||||
<div class="translate-fab-wrap" :style="wrapStyle">
|
||||
<div v-if="open" class="translate-fab-backdrop" @click="close" />
|
||||
|
||||
<transition name="translate-panel">
|
||||
@@ -128,20 +136,7 @@ watch(
|
||||
<div v-if="message" :class="['message', messageType]">{{ message }}</div>
|
||||
|
||||
<div v-if="result" class="translate-result">
|
||||
<div class="lang-dir">{{ langLabel(result.source_lang) }}</div>
|
||||
<div class="result-main">{{ result.target_text }}</div>
|
||||
<div v-if="result.phonetic" class="result-meta">{{ result.phonetic }}</div>
|
||||
<div v-if="result.example_en" class="result-example">
|
||||
<span>{{ result.example_en }}</span>
|
||||
<span v-if="result.example_cn" class="example-cn">{{ result.example_cn }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary add-btn"
|
||||
:disabled="adding"
|
||||
@click="addToLibrary"
|
||||
>
|
||||
{{ adding ? '添加中...' : '加入单词库' }}
|
||||
</button>
|
||||
<TranslateResultPanel v-model="result" compact :adding="adding" @add="addToLibrary" />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
@@ -149,9 +144,10 @@ watch(
|
||||
<button
|
||||
type="button"
|
||||
class="translate-fab-btn"
|
||||
:class="{ open }"
|
||||
:class="{ open, dragging }"
|
||||
aria-label="快速翻译"
|
||||
:aria-expanded="open"
|
||||
@pointerdown="onFabPointerDown"
|
||||
@click="handleToggle"
|
||||
>
|
||||
<span class="fab-icon">{{ open ? '×' : '🔤' }}</span>
|
||||
@@ -163,9 +159,6 @@ watch(
|
||||
<style scoped>
|
||||
.translate-fab-wrap {
|
||||
position: fixed;
|
||||
left: calc(16px + env(safe-area-inset-left));
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 110;
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
@@ -220,46 +213,10 @@ watch(
|
||||
}
|
||||
|
||||
.translate-result {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.lang-dir {
|
||||
font-size: 11px;
|
||||
color: var(--primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.result-main {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.result-example {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.example-cn {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.translate-fab-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -273,14 +230,21 @@ watch(
|
||||
color: var(--primary);
|
||||
border: 2px solid var(--primary);
|
||||
box-shadow: 0 4px 16px rgba(79, 110, 247, 0.25);
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
transition: transform 0.15s, box-shadow 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.translate-fab-btn:hover {
|
||||
.translate-fab-btn:hover:not(.dragging) {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.translate-fab-btn.dragging {
|
||||
cursor: grabbing;
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 6px 20px rgba(79, 110, 247, 0.35);
|
||||
}
|
||||
|
||||
.translate-fab-btn.open {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { TranslateResult } from '../api/request'
|
||||
|
||||
const result = defineModel<TranslateResult>({ required: true })
|
||||
|
||||
defineProps<{
|
||||
adding?: boolean
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
add: []
|
||||
}>()
|
||||
|
||||
const langLabel = computed(() =>
|
||||
result.value.source_lang === 'zh' ? '中文 → 英文' : '英文 → 中文'
|
||||
)
|
||||
|
||||
const targetLabel = computed(() =>
|
||||
result.value.target_lang === 'en' ? '英文译文' : '中文译文'
|
||||
)
|
||||
|
||||
const canAdd = computed(() => result.value.target_text.trim().length > 0)
|
||||
|
||||
function updateField<K extends keyof TranslateResult>(key: K, value: TranslateResult[K]) {
|
||||
result.value = { ...result.value, [key]: value }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['translate-result-panel', { compact }]">
|
||||
<div class="lang-dir">{{ langLabel }}</div>
|
||||
|
||||
<div v-if="result.found === false" class="message warning not-found-banner">
|
||||
词典未收录,请手动填写译文后再加入单词库
|
||||
</div>
|
||||
|
||||
<div class="result-row">
|
||||
<span class="label-sm">原文</span>
|
||||
<span class="source-text">{{ result.source_text }}</span>
|
||||
</div>
|
||||
|
||||
<div class="result-row" :class="{ main: !compact }">
|
||||
<span class="label-sm">{{ targetLabel }}</span>
|
||||
<input
|
||||
v-if="result.found === false"
|
||||
:value="result.target_text"
|
||||
class="input edit-target"
|
||||
:placeholder="result.target_lang === 'en' ? '输入英文' : '输入中文'"
|
||||
@input="updateField('target_text', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<span v-else class="target-text">{{ result.target_text }}</span>
|
||||
</div>
|
||||
|
||||
<template v-if="result.found === false">
|
||||
<div class="result-row">
|
||||
<span class="label-sm">音标(可选)</span>
|
||||
<input
|
||||
:value="result.phonetic ?? ''"
|
||||
class="input edit-field"
|
||||
placeholder="/音标/"
|
||||
@input="updateField('phonetic', ($event.target as HTMLInputElement).value || undefined)"
|
||||
/>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span class="label-sm">英文例句(可选)</span>
|
||||
<input
|
||||
:value="result.example_en ?? ''"
|
||||
class="input edit-field"
|
||||
placeholder="Example sentence"
|
||||
@input="updateField('example_en', ($event.target as HTMLInputElement).value || undefined)"
|
||||
/>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span class="label-sm">中文例句(可选)</span>
|
||||
<input
|
||||
:value="result.example_cn ?? ''"
|
||||
class="input edit-field"
|
||||
placeholder="例句翻译"
|
||||
@input="updateField('example_cn', ($event.target as HTMLInputElement).value || undefined)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="result.phonetic" class="result-row">
|
||||
<span class="label-sm">音标</span>
|
||||
<span>{{ result.phonetic }}</span>
|
||||
</div>
|
||||
<div v-if="result.example_en" class="result-row">
|
||||
<span class="label-sm">英文例句</span>
|
||||
<span>{{ result.example_en }}</span>
|
||||
</div>
|
||||
<div v-if="result.example_cn" class="result-row">
|
||||
<span class="label-sm">中文例句</span>
|
||||
<span>{{ result.example_cn }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<button
|
||||
class="btn btn-primary add-btn"
|
||||
:disabled="adding || !canAdd"
|
||||
@click="emit('add')"
|
||||
>
|
||||
{{ adding ? '添加中...' : '加入单词库' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.translate-result-panel {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.not-found-banner {
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.lang-dir {
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.compact .lang-dir {
|
||||
font-size: 11px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.result-row {
|
||||
margin-bottom: 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.compact .result-row {
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.result-row.main .target-text {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.compact .result-row.main .target-text,
|
||||
.compact .edit-target {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.label-sm {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.compact .label-sm {
|
||||
font-size: 11px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.source-text,
|
||||
.target-text {
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.edit-target,
|
||||
.edit-field {
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.edit-target {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.edit-field {
|
||||
font-size: 14px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
import { computed, onMounted, onUnmounted, ref, type CSSProperties } from 'vue'
|
||||
|
||||
const FAB_SIZE = 56
|
||||
const EDGE = 8
|
||||
const BOTTOM_RESERVED = 64
|
||||
const DRAG_THRESHOLD = 6
|
||||
|
||||
type Position = { x: number; y: number }
|
||||
|
||||
function clampPosition(x: number, y: number): Position {
|
||||
const maxX = Math.max(EDGE, window.innerWidth - FAB_SIZE - EDGE)
|
||||
const maxY = Math.max(EDGE, window.innerHeight - FAB_SIZE - BOTTOM_RESERVED - EDGE)
|
||||
return {
|
||||
x: Math.min(maxX, Math.max(EDGE, x)),
|
||||
y: Math.min(maxY, Math.max(EDGE, y)),
|
||||
}
|
||||
}
|
||||
|
||||
export const FAB_POSITION_KEYS = ['wordloop-fab-train', 'wordloop-fab-translate'] as const
|
||||
|
||||
/** 登录/退出时清除,悬浮按钮恢复默认位置 */
|
||||
export function clearFabPositions() {
|
||||
for (const key of FAB_POSITION_KEYS) {
|
||||
try {
|
||||
sessionStorage.removeItem(key)
|
||||
localStorage.removeItem(key)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadPosition(key: string): Position | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as Position
|
||||
if (typeof parsed.x === 'number' && typeof parsed.y === 'number') return parsed
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function savePosition(key: string, pos: Position) {
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify(pos))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function useDraggableFab(storageKey: string, getDefault: () => Position) {
|
||||
const x = ref(0)
|
||||
const y = ref(0)
|
||||
const dragging = ref(false)
|
||||
const dragMoved = ref(false)
|
||||
|
||||
function applyPosition(pos: Position) {
|
||||
const clamped = clampPosition(pos.x, pos.y)
|
||||
x.value = clamped.x
|
||||
y.value = clamped.y
|
||||
}
|
||||
|
||||
function init() {
|
||||
const saved = loadPosition(storageKey)
|
||||
applyPosition(saved ?? getDefault())
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
applyPosition({ x: x.value, y: y.value })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
init()
|
||||
window.addEventListener('resize', onResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
})
|
||||
|
||||
const wrapStyle = computed<CSSProperties>(() => ({
|
||||
left: `${x.value}px`,
|
||||
top: `${y.value}px`,
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
transform: 'none',
|
||||
}))
|
||||
|
||||
function onFabPointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
const el = e.currentTarget as HTMLElement
|
||||
el.setPointerCapture(e.pointerId)
|
||||
|
||||
const startX = e.clientX
|
||||
const startY = e.clientY
|
||||
const originX = x.value
|
||||
const originY = y.value
|
||||
dragMoved.value = false
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const dx = ev.clientX - startX
|
||||
const dy = ev.clientY - startY
|
||||
if (!dragMoved.value && Math.hypot(dx, dy) < DRAG_THRESHOLD) return
|
||||
dragMoved.value = true
|
||||
dragging.value = true
|
||||
applyPosition({ x: originX + dx, y: originY + dy })
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
el.releasePointerCapture(e.pointerId)
|
||||
el.removeEventListener('pointermove', onMove)
|
||||
el.removeEventListener('pointerup', onUp)
|
||||
el.removeEventListener('pointercancel', onUp)
|
||||
if (dragMoved.value) {
|
||||
savePosition(storageKey, { x: x.value, y: y.value })
|
||||
}
|
||||
dragging.value = false
|
||||
}
|
||||
|
||||
el.addEventListener('pointermove', onMove)
|
||||
el.addEventListener('pointerup', onUp)
|
||||
el.addEventListener('pointercancel', onUp)
|
||||
}
|
||||
|
||||
function consumeDragClick(): boolean {
|
||||
if (dragMoved.value) {
|
||||
dragMoved.value = false
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return { wrapStyle, dragging, onFabPointerDown, consumeDragClick }
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import type { QuizQuestion } from '../api/request'
|
||||
import type { QuizQuestion, QuizTrack } from '../api/request'
|
||||
import { getToken } from '../utils/auth'
|
||||
|
||||
export type QuizMode = 'daily' | 'spell'
|
||||
|
||||
export function quizSessionKey(mode: QuizMode, track: QuizTrack = 'accumulation') {
|
||||
return `${mode}_${track}` as `${QuizMode}_${QuizTrack}`
|
||||
}
|
||||
|
||||
export interface QuizSessionSnapshot {
|
||||
questions: QuizQuestion[]
|
||||
currentIndex: number
|
||||
@@ -30,12 +34,12 @@ export interface PendingAnswer {
|
||||
duration_seconds?: number
|
||||
}
|
||||
|
||||
function sessionKey(mode: QuizMode) {
|
||||
return `wordloop_quiz_session_${mode}`
|
||||
function sessionKey(mode: QuizMode, track: QuizTrack = 'accumulation') {
|
||||
return `wordloop_quiz_session_${mode}_${track}`
|
||||
}
|
||||
|
||||
function pendingKey(mode: QuizMode) {
|
||||
return `wordloop_quiz_pending_${mode}`
|
||||
function pendingKey(mode: QuizMode, track: QuizTrack = 'accumulation') {
|
||||
return `wordloop_quiz_pending_${mode}_${track}`
|
||||
}
|
||||
|
||||
function todayStr() {
|
||||
@@ -60,51 +64,62 @@ function writeJson(key: string, value: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
export function saveQuizSession(mode: QuizMode, snapshot: QuizSessionSnapshot) {
|
||||
export function saveQuizSession(
|
||||
mode: QuizMode,
|
||||
snapshot: QuizSessionSnapshot,
|
||||
track: QuizTrack = 'accumulation'
|
||||
) {
|
||||
if (!snapshot.questions.length) return
|
||||
const stored: StoredSession = {
|
||||
...snapshot,
|
||||
date: todayStr(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
writeJson(sessionKey(mode), stored)
|
||||
writeJson(sessionKey(mode, track), stored)
|
||||
}
|
||||
|
||||
export function loadQuizSession(mode: QuizMode): StoredSession | null {
|
||||
const stored = readJson<StoredSession>(sessionKey(mode))
|
||||
export function loadQuizSession(
|
||||
mode: QuizMode,
|
||||
track: QuizTrack = 'accumulation'
|
||||
): StoredSession | null {
|
||||
const stored = readJson<StoredSession>(sessionKey(mode, track))
|
||||
if (!stored?.questions?.length) return null
|
||||
if (stored.date !== todayStr()) {
|
||||
clearQuizSession(mode)
|
||||
clearQuizSession(mode, track)
|
||||
return null
|
||||
}
|
||||
return stored
|
||||
}
|
||||
|
||||
export function clearQuizSession(mode: QuizMode) {
|
||||
localStorage.removeItem(sessionKey(mode))
|
||||
localStorage.removeItem(pendingKey(mode))
|
||||
export function clearQuizSession(mode: QuizMode, track: QuizTrack = 'accumulation') {
|
||||
localStorage.removeItem(sessionKey(mode, track))
|
||||
localStorage.removeItem(pendingKey(mode, track))
|
||||
}
|
||||
|
||||
function getPendingList(mode: QuizMode): PendingAnswer[] {
|
||||
return readJson<PendingAnswer[]>(pendingKey(mode)) ?? []
|
||||
function getPendingList(mode: QuizMode, track: QuizTrack = 'accumulation'): PendingAnswer[] {
|
||||
return readJson<PendingAnswer[]>(pendingKey(mode, track)) ?? []
|
||||
}
|
||||
|
||||
function setPendingList(mode: QuizMode, list: PendingAnswer[]) {
|
||||
function setPendingList(mode: QuizMode, list: PendingAnswer[], track: QuizTrack = 'accumulation') {
|
||||
if (list.length === 0) {
|
||||
localStorage.removeItem(pendingKey(mode))
|
||||
localStorage.removeItem(pendingKey(mode, track))
|
||||
} else {
|
||||
writeJson(pendingKey(mode), list)
|
||||
writeJson(pendingKey(mode, track), list)
|
||||
}
|
||||
}
|
||||
|
||||
export function enqueuePendingAnswer(mode: QuizMode, payload: PendingAnswer) {
|
||||
const list = getPendingList(mode)
|
||||
export function enqueuePendingAnswer(
|
||||
mode: QuizMode,
|
||||
payload: PendingAnswer,
|
||||
track: QuizTrack = 'accumulation'
|
||||
) {
|
||||
const list = getPendingList(mode, track)
|
||||
const exists = list.some(
|
||||
(p) => p.word_id === payload.word_id && p.question_type === payload.question_type
|
||||
)
|
||||
if (!exists) {
|
||||
list.push(payload)
|
||||
setPendingList(mode, list)
|
||||
setPendingList(mode, list, track)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,8 +137,11 @@ function postAnswerKeepalive(payload: PendingAnswer) {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
export async function flushPendingAnswers(mode: QuizMode): Promise<void> {
|
||||
const list = getPendingList(mode)
|
||||
export async function flushPendingAnswers(
|
||||
mode: QuizMode,
|
||||
track: QuizTrack = 'accumulation'
|
||||
): Promise<void> {
|
||||
const list = getPendingList(mode, track)
|
||||
if (!list.length) return
|
||||
|
||||
const remaining: PendingAnswer[] = []
|
||||
@@ -143,19 +161,23 @@ export async function flushPendingAnswers(mode: QuizMode): Promise<void> {
|
||||
remaining.push(item)
|
||||
}
|
||||
}
|
||||
setPendingList(mode, remaining)
|
||||
setPendingList(mode, remaining, track)
|
||||
}
|
||||
|
||||
export function flushPendingAnswersKeepalive(mode: QuizMode) {
|
||||
for (const item of getPendingList(mode)) {
|
||||
export function flushPendingAnswersKeepalive(
|
||||
mode: QuizMode,
|
||||
track: QuizTrack = 'accumulation'
|
||||
) {
|
||||
for (const item of getPendingList(mode, track)) {
|
||||
postAnswerKeepalive(item)
|
||||
}
|
||||
localStorage.removeItem(pendingKey(mode))
|
||||
localStorage.removeItem(pendingKey(mode, track))
|
||||
}
|
||||
|
||||
export async function submitQuizAnswer(
|
||||
mode: QuizMode,
|
||||
payload: PendingAnswer
|
||||
payload: PendingAnswer,
|
||||
track: QuizTrack = 'accumulation'
|
||||
): Promise<{ ok: boolean; is_correct?: boolean; correct_answer?: string }> {
|
||||
const token = getToken()
|
||||
try {
|
||||
@@ -171,7 +193,7 @@ export async function submitQuizAnswer(
|
||||
const data = await res.json()
|
||||
return { ok: true, is_correct: data.is_correct, correct_answer: data.correct_answer }
|
||||
} catch {
|
||||
enqueuePendingAnswer(mode, payload)
|
||||
enqueuePendingAnswer(mode, payload, track)
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
@@ -185,16 +207,17 @@ export interface QuizAutoSaveOptions {
|
||||
export function useQuizAutoSave(
|
||||
mode: QuizMode,
|
||||
getSnapshot: () => QuizSessionSnapshot | null,
|
||||
options: QuizAutoSaveOptions = {}
|
||||
options: QuizAutoSaveOptions = {},
|
||||
track: QuizTrack = 'accumulation'
|
||||
) {
|
||||
const persist = () => {
|
||||
const snap = getSnapshot()
|
||||
if (!snap) return
|
||||
if (snap.finished) {
|
||||
clearQuizSession(mode)
|
||||
clearQuizSession(mode, track)
|
||||
return
|
||||
}
|
||||
saveQuizSession(mode, snap)
|
||||
saveQuizSession(mode, snap, track)
|
||||
}
|
||||
|
||||
const flushDraft = async () => {
|
||||
@@ -206,7 +229,7 @@ export function useQuizAutoSave(
|
||||
const onInterrupt = async () => {
|
||||
await flushDraft()
|
||||
persist()
|
||||
await flushPendingAnswers(mode)
|
||||
await flushPendingAnswers(mode, track)
|
||||
}
|
||||
|
||||
const onInterruptSync = () => {
|
||||
@@ -215,7 +238,7 @@ export function useQuizAutoSave(
|
||||
void options.onDraftSubmit(draft)
|
||||
}
|
||||
persist()
|
||||
flushPendingAnswersKeepalive(mode)
|
||||
flushPendingAnswersKeepalive(mode, track)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -241,5 +264,5 @@ export function useQuizAutoSave(
|
||||
await onInterrupt()
|
||||
})
|
||||
|
||||
return { persist, flushPending: () => flushPendingAnswers(mode) }
|
||||
return { persist, flushPending: () => flushPendingAnswers(mode, track) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export interface WordLoopRuntime {
|
||||
embedded: boolean
|
||||
apiBase: string
|
||||
iosApp: boolean
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__WORDLOOP_RUNTIME__?: Partial<WordLoopRuntime>
|
||||
__wordloopSetApiBase?: (base: string) => void
|
||||
__WORDLOOP_BOOT_ERROR__?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function getRuntime(): WordLoopRuntime {
|
||||
const injected = window.__WORDLOOP_RUNTIME__ ?? {}
|
||||
return {
|
||||
embedded: !!injected.embedded,
|
||||
apiBase: injected.apiBase || '/api',
|
||||
iosApp: !!injected.iosApp,
|
||||
}
|
||||
}
|
||||
|
||||
export function currentAppPath(): string {
|
||||
if (getRuntime().embedded) {
|
||||
const hash = window.location.hash.replace(/^#/, '')
|
||||
return (hash.split('?')[0] || '/').trim() || '/'
|
||||
}
|
||||
return window.location.pathname
|
||||
}
|
||||
|
||||
export function isAuthPath(): boolean {
|
||||
const path = currentAppPath()
|
||||
return path === '/login' || path === '/register'
|
||||
}
|
||||
|
||||
/** iOS 内嵌 file:// 场景下不能用 location.href='/login',会跳到 file:///login */
|
||||
export function appNavigate(path: string) {
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`
|
||||
if (getRuntime().embedded) {
|
||||
const base = `${window.location.pathname}${window.location.search}`
|
||||
window.location.replace(`${base}#${normalized}`)
|
||||
return
|
||||
}
|
||||
window.location.href = normalized
|
||||
}
|
||||
+41
-18
@@ -1,12 +1,19 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { getRuntime } from './config/runtime'
|
||||
import './styles.css'
|
||||
|
||||
const runtime = getRuntime()
|
||||
if (runtime.iosApp) {
|
||||
document.documentElement.classList.add('wordloop-ios-app')
|
||||
}
|
||||
|
||||
const CHUNK_RELOAD_KEY = 'wordloop-chunk-reload'
|
||||
|
||||
/** 部署后旧 bundle 引用已删除的 chunk 时,强制拉取最新 index.html */
|
||||
function reloadOnStaleChunk(): void {
|
||||
if (runtime.embedded) return
|
||||
const attempts = Number(sessionStorage.getItem(CHUNK_RELOAD_KEY) || '0')
|
||||
if (attempts >= 2) return
|
||||
sessionStorage.setItem(CHUNK_RELOAD_KEY, String(attempts + 1))
|
||||
@@ -26,24 +33,40 @@ function isStaleChunkError(reason: unknown): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
window.addEventListener('vite:preloadError', (event) => {
|
||||
event.preventDefault()
|
||||
reloadOnStaleChunk()
|
||||
})
|
||||
if (!runtime.embedded) {
|
||||
window.addEventListener('vite:preloadError', (event) => {
|
||||
event.preventDefault()
|
||||
reloadOnStaleChunk()
|
||||
})
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
if (!isStaleChunkError(event.reason)) return
|
||||
event.preventDefault()
|
||||
reloadOnStaleChunk()
|
||||
})
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
if (!isStaleChunkError(event.reason)) return
|
||||
event.preventDefault()
|
||||
reloadOnStaleChunk()
|
||||
})
|
||||
|
||||
router.onError((error) => {
|
||||
if (!isStaleChunkError(error)) return
|
||||
reloadOnStaleChunk()
|
||||
})
|
||||
router.onError((error) => {
|
||||
if (!isStaleChunkError(error)) return
|
||||
reloadOnStaleChunk()
|
||||
})
|
||||
}
|
||||
|
||||
const app = createApp(App).use(router)
|
||||
app.mount('#app')
|
||||
router.isReady().then(() => {
|
||||
sessionStorage.removeItem(CHUNK_RELOAD_KEY)
|
||||
})
|
||||
function bootApp() {
|
||||
try {
|
||||
const app = createApp(App).use(router)
|
||||
app.mount('#app')
|
||||
router.isReady().then(() => {
|
||||
sessionStorage.removeItem(CHUNK_RELOAD_KEY)
|
||||
})
|
||||
} catch (error) {
|
||||
window.__WORDLOOP_BOOT_ERROR__ = error instanceof Error ? error.message : String(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// iOS 内联脚本在 <head> 执行时 #app 尚未解析,必须等 DOM 就绪
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', bootApp, { once: true })
|
||||
} else {
|
||||
bootApp()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api, type Settings } from '../api/request'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const dailyTarget = ref(20)
|
||||
const masterRequired = ref(3)
|
||||
const weakThreshold = ref(3)
|
||||
const loading = ref(true)
|
||||
const saveState = ref<'idle' | 'saving' | 'success' | 'error'>('idle')
|
||||
const toast = ref<{ text: string; type: 'success' | 'error' } | null>(null)
|
||||
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let successTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const saveLabel = computed(() => {
|
||||
if (saveState.value === 'saving') return '保存中...'
|
||||
if (saveState.value === 'success') return '已保存 ✓'
|
||||
return '保存设置'
|
||||
})
|
||||
|
||||
function applySettings(data: Settings) {
|
||||
dailyTarget.value = data.daily_target
|
||||
masterRequired.value = data.master_required_count
|
||||
weakThreshold.value = data.weak_wrong_threshold
|
||||
}
|
||||
|
||||
function showToast(text: string, type: 'success' | 'error') {
|
||||
if (toastTimer) clearTimeout(toastTimer)
|
||||
toast.value = { text, type }
|
||||
toastTimer = setTimeout(() => {
|
||||
toast.value = null
|
||||
}, 2800)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.getSettings()
|
||||
applySettings(data)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
if (saveState.value === 'saving') return
|
||||
saveState.value = 'saving'
|
||||
try {
|
||||
const { data } = await api.updateSettings({
|
||||
daily_target: dailyTarget.value,
|
||||
master_required_count: masterRequired.value,
|
||||
weak_wrong_threshold: weakThreshold.value,
|
||||
})
|
||||
applySettings(data)
|
||||
saveState.value = 'success'
|
||||
showToast('日常积累设置已生效', 'success')
|
||||
if (successTimer) clearTimeout(successTimer)
|
||||
successTimer = setTimeout(() => {
|
||||
if (saveState.value === 'success') saveState.value = 'idle'
|
||||
}, 2200)
|
||||
} catch {
|
||||
saveState.value = 'error'
|
||||
showToast('保存失败,请检查网络后重试', 'error')
|
||||
setTimeout(() => {
|
||||
if (saveState.value === 'error') saveState.value = 'idle'
|
||||
}, 2200)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="header-row">
|
||||
<button type="button" class="back-btn" @click="router.back()">返回</button>
|
||||
<h1 class="page-title">学习设置</h1>
|
||||
</div>
|
||||
<p class="page-sub">调整日常积累的每日练习量与学习规则</p>
|
||||
|
||||
<p v-if="loading" class="muted">加载中...</p>
|
||||
<template v-else>
|
||||
<section class="block">
|
||||
<h2 class="block-title">练习参数</h2>
|
||||
<p class="block-hint">仅作用于翻译加词与日常积累练习</p>
|
||||
<div class="field">
|
||||
<label>每日练习量</label>
|
||||
<input v-model.number="dailyTarget" type="range" min="1" max="100" step="1" />
|
||||
<span class="field-value">{{ dailyTarget }} 词/天</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>连续答对几次算掌握</label>
|
||||
<input v-model.number="masterRequired" type="range" min="1" max="20" step="1" />
|
||||
<span class="field-value">{{ masterRequired }} 次</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>答错几次标记易错</label>
|
||||
<input v-model.number="weakThreshold" type="range" min="1" max="20" step="1" />
|
||||
<span class="field-value">{{ weakThreshold }} 次</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn save-btn"
|
||||
:class="{
|
||||
'btn-primary': saveState === 'idle' || saveState === 'saving',
|
||||
'save-success': saveState === 'success',
|
||||
'save-error': saveState === 'error',
|
||||
}"
|
||||
:disabled="saveState === 'saving' || saveState === 'success'"
|
||||
@click="save"
|
||||
>
|
||||
{{ saveLabel }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<Transition name="toast">
|
||||
<div
|
||||
v-if="toast"
|
||||
class="toast"
|
||||
:class="toast.type"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="toast-icon">{{ toast.type === 'success' ? '✓' : '!' }}</span>
|
||||
<span class="toast-text">{{ toast.text }}</span>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.back-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--primary, #4f6ef7);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.page-sub {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.muted { color: var(--muted); }
|
||||
.block {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.block-title {
|
||||
font-size: 16px;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.block-hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.field input[type='range'] {
|
||||
width: 100%;
|
||||
}
|
||||
.field-value {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.save-btn {
|
||||
width: 100%;
|
||||
transition: background 0.25s ease, transform 0.15s ease;
|
||||
}
|
||||
.save-btn.save-success {
|
||||
background: #4f6ef7;
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
.save-btn.save-error {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(24px + env(safe-area-inset-bottom, 0px));
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
padding: 14px 18px;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.14);
|
||||
z-index: 200;
|
||||
font-size: 14px;
|
||||
}
|
||||
.toast.success {
|
||||
background: #4338ca;
|
||||
color: #fff;
|
||||
}
|
||||
.toast.error {
|
||||
background: #fff;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.toast.success .toast-icon {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.toast.error .toast-icon {
|
||||
background: #fee2e2;
|
||||
}
|
||||
.toast-text {
|
||||
line-height: 1.4;
|
||||
}
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(12px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,368 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api, type ActiveBookState, type WordBook } from '../api/request'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const books = ref<WordBook[]>([])
|
||||
const active = ref<ActiveBookState | null>(null)
|
||||
const selectedBookId = ref<number | null>(null)
|
||||
const dailyTarget = ref(12)
|
||||
const masterRequired = ref(3)
|
||||
const weakThreshold = ref(2)
|
||||
const loading = ref(true)
|
||||
const saveState = ref<'idle' | 'saving' | 'success' | 'error'>('idle')
|
||||
const toast = ref<{ text: string; type: 'success' | 'error' } | null>(null)
|
||||
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let successTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const LEVEL_GROUPS = [
|
||||
{ key: 'primary', label: '小学(按年级)' },
|
||||
{ key: 'junior', label: '初中(按年级)' },
|
||||
{ key: 'senior', label: '高中(按年级)' },
|
||||
{ key: 'business', label: '商务' },
|
||||
] as const
|
||||
|
||||
const groupedBooks = computed(() =>
|
||||
LEVEL_GROUPS.map((g) => ({
|
||||
...g,
|
||||
books: books.value.filter((b) => b.level === g.key),
|
||||
})).filter((g) => g.books.length > 0)
|
||||
)
|
||||
|
||||
const selectedBook = computed(() =>
|
||||
books.value.find((b) => b.id === selectedBookId.value) ?? null
|
||||
)
|
||||
|
||||
const saveLabel = computed(() => {
|
||||
if (saveState.value === 'saving') return '保存中...'
|
||||
if (saveState.value === 'success') return '已保存 ✓'
|
||||
return '保存设置'
|
||||
})
|
||||
|
||||
function applyParamsFromBook(book: WordBook) {
|
||||
dailyTarget.value = book.daily_target
|
||||
masterRequired.value = book.master_required_count
|
||||
weakThreshold.value = book.weak_wrong_threshold
|
||||
}
|
||||
|
||||
function applyActive(data: ActiveBookState) {
|
||||
active.value = data
|
||||
if (data.book) {
|
||||
selectedBookId.value = data.book.id
|
||||
}
|
||||
if (data.settings) {
|
||||
dailyTarget.value = data.settings.daily_target
|
||||
masterRequired.value = data.settings.master_required_count
|
||||
weakThreshold.value = data.settings.weak_wrong_threshold
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(text: string, type: 'success' | 'error') {
|
||||
if (toastTimer) clearTimeout(toastTimer)
|
||||
toast.value = { text, type }
|
||||
toastTimer = setTimeout(() => {
|
||||
toast.value = null
|
||||
}, 2800)
|
||||
}
|
||||
|
||||
function syncParamsForSelection(bookId: number | null) {
|
||||
if (!bookId) return
|
||||
const book = books.value.find((b) => b.id === bookId)
|
||||
if (!book) return
|
||||
if (active.value?.book?.id === bookId && active.value.settings) {
|
||||
dailyTarget.value = active.value.settings.daily_target
|
||||
masterRequired.value = active.value.settings.master_required_count
|
||||
weakThreshold.value = active.value.settings.weak_wrong_threshold
|
||||
} else {
|
||||
applyParamsFromBook(book)
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedBookId, (id) => {
|
||||
if (loading.value) return
|
||||
syncParamsForSelection(id)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [booksRes, activeRes] = await Promise.all([api.listBooks(), api.getActiveBook()])
|
||||
books.value = booksRes.data
|
||||
applyActive(activeRes.data)
|
||||
if (!selectedBookId.value && books.value.length) {
|
||||
selectedBookId.value = books.value[0].id
|
||||
syncParamsForSelection(selectedBookId.value)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
if (!selectedBookId.value || saveState.value === 'saving') return
|
||||
saveState.value = 'saving'
|
||||
try {
|
||||
const { data } = await api.updateBookSettings({
|
||||
book_id: selectedBookId.value,
|
||||
daily_target: dailyTarget.value,
|
||||
master_required_count: masterRequired.value,
|
||||
weak_wrong_threshold: weakThreshold.value,
|
||||
})
|
||||
applyActive(data)
|
||||
saveState.value = 'success'
|
||||
const title = data.book?.title ?? '词书'
|
||||
showToast(`「${title}」设置已生效`, 'success')
|
||||
if (successTimer) clearTimeout(successTimer)
|
||||
successTimer = setTimeout(() => {
|
||||
if (saveState.value === 'success') saveState.value = 'idle'
|
||||
}, 2200)
|
||||
} catch {
|
||||
saveState.value = 'error'
|
||||
showToast('保存失败,请检查网络后重试', 'error')
|
||||
setTimeout(() => {
|
||||
if (saveState.value === 'error') saveState.value = 'idle'
|
||||
}, 2200)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="header-row">
|
||||
<button type="button" class="back-btn" @click="router.back()">返回</button>
|
||||
<h1 class="page-title">词书设置</h1>
|
||||
</div>
|
||||
<p class="page-sub">选择词书并调整每日练习量与学习规则</p>
|
||||
|
||||
<p v-if="loading" class="muted">加载中...</p>
|
||||
<template v-else>
|
||||
<section class="block">
|
||||
<h2 class="block-title">选择词书</h2>
|
||||
<p class="block-hint">一次仅学一本;小学按年级选择</p>
|
||||
|
||||
<div class="select-wrap">
|
||||
<select
|
||||
v-model.number="selectedBookId"
|
||||
class="book-select"
|
||||
aria-label="选择词书"
|
||||
>
|
||||
<option v-if="!selectedBookId" :value="null" disabled>请选择词书</option>
|
||||
<optgroup v-for="group in groupedBooks" :key="group.key" :label="group.label">
|
||||
<option v-for="book in group.books" :key="book.id" :value="book.id">
|
||||
{{ book.title }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedBook" class="book-summary">
|
||||
<span>{{ selectedBook.word_count }} 词</span>
|
||||
<span class="dot">·</span>
|
||||
<span>{{ selectedBook.unit_count }} 单元</span>
|
||||
<span class="dot">·</span>
|
||||
<span>默认 {{ selectedBook.daily_target }} 词/天</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h2 class="block-title">练习参数</h2>
|
||||
<div class="field">
|
||||
<label>每日练习量</label>
|
||||
<input v-model.number="dailyTarget" type="range" min="5" max="40" step="1" />
|
||||
<span class="field-value">{{ dailyTarget }} 词/天</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>连续答对几次算掌握</label>
|
||||
<input v-model.number="masterRequired" type="range" min="2" max="8" step="1" />
|
||||
<span class="field-value">{{ masterRequired }} 次</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>答错几次标记易错</label>
|
||||
<input v-model.number="weakThreshold" type="range" min="1" max="5" step="1" />
|
||||
<span class="field-value">{{ weakThreshold }} 次</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn save-btn"
|
||||
:class="{
|
||||
'btn-primary': saveState === 'idle' || saveState === 'saving',
|
||||
'save-success': saveState === 'success',
|
||||
'save-error': saveState === 'error',
|
||||
}"
|
||||
:disabled="!selectedBookId || saveState === 'saving' || saveState === 'success'"
|
||||
@click="save"
|
||||
>
|
||||
{{ saveLabel }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<Transition name="toast">
|
||||
<div
|
||||
v-if="toast"
|
||||
class="toast"
|
||||
:class="toast.type"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="toast-icon">{{ toast.type === 'success' ? '✓' : '!' }}</span>
|
||||
<span class="toast-text">{{ toast.text }}</span>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.back-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--primary, #4f6ef7);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.page-sub {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.muted { color: var(--muted); }
|
||||
.block {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.block-title {
|
||||
font-size: 16px;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.block-hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.select-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.book-select {
|
||||
width: 100%;
|
||||
appearance: none;
|
||||
padding: 14px 40px 14px 14px;
|
||||
font-size: 15px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border, #e5e7eb);
|
||||
background: var(--card, #fff)
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%236b7280' d='M1.41 0L6 4.58 10.59 0 12 1.41l-6 6-6-6z'/%3E%3C/svg%3E")
|
||||
no-repeat right 14px center;
|
||||
color: var(--text, #111);
|
||||
cursor: pointer;
|
||||
}
|
||||
.book-select:focus {
|
||||
outline: none;
|
||||
border-color: #0d9488;
|
||||
box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.15);
|
||||
}
|
||||
.book-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.book-summary .dot {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.field input[type='range'] {
|
||||
width: 100%;
|
||||
}
|
||||
.field-value {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.save-btn {
|
||||
width: 100%;
|
||||
transition: background 0.25s ease, transform 0.15s ease;
|
||||
}
|
||||
.save-btn.save-success {
|
||||
background: #0d9488;
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
.save-btn.save-error {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(24px + env(safe-area-inset-bottom, 0px));
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
padding: 14px 18px;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.14);
|
||||
z-index: 200;
|
||||
font-size: 14px;
|
||||
}
|
||||
.toast.success {
|
||||
background: #0f766e;
|
||||
color: #fff;
|
||||
}
|
||||
.toast.error {
|
||||
background: #fff;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.toast.success .toast-icon {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.toast.error .toast-icon {
|
||||
background: #fee2e2;
|
||||
}
|
||||
.toast-text {
|
||||
line-height: 1.4;
|
||||
}
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(12px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import QuizCard from '../components/QuizCard.vue'
|
||||
import { api, type QuizQuestion } from '../api/request'
|
||||
import { api, type QuizQuestion, type QuizTrack } from '../api/request'
|
||||
import {
|
||||
clearQuizSession,
|
||||
loadQuizSession,
|
||||
@@ -11,6 +12,14 @@ import {
|
||||
} from '../composables/useQuizSession'
|
||||
import { useQuizTimer } from '../composables/useQuizTimer'
|
||||
|
||||
const route = useRoute()
|
||||
const track = computed<QuizTrack>(() =>
|
||||
route.query.track === 'book' ? 'book' : 'accumulation'
|
||||
)
|
||||
const pageTitle = computed(() =>
|
||||
track.value === 'book' ? '词书每日训练' : '积累每日训练'
|
||||
)
|
||||
|
||||
const { startQuestionTimer, consumeDurationSeconds } = useQuizTimer()
|
||||
|
||||
const questions = ref<QuizQuestion[]>([])
|
||||
@@ -41,7 +50,7 @@ function buildSnapshot(): QuizSessionSnapshot | null {
|
||||
}
|
||||
}
|
||||
|
||||
const { persist, flushPending } = useQuizAutoSave('daily', buildSnapshot)
|
||||
const { persist, flushPending } = useQuizAutoSave('daily', buildSnapshot, {}, track.value)
|
||||
|
||||
function applySnapshot(snap: QuizSessionSnapshot) {
|
||||
questions.value = snap.questions
|
||||
@@ -56,14 +65,14 @@ function applySnapshot(snap: QuizSessionSnapshot) {
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const saved = loadQuizSession('daily')
|
||||
const saved = loadQuizSession('daily', track.value)
|
||||
if (saved && !saved.finished) {
|
||||
applySnapshot(saved)
|
||||
restored.value = true
|
||||
await flushPending()
|
||||
} else {
|
||||
clearQuizSession('daily')
|
||||
const { data } = await api.dailyQuiz()
|
||||
clearQuizSession('daily', track.value)
|
||||
const { data } = await api.dailyQuiz(track.value)
|
||||
questions.value = data.questions
|
||||
if (data.questions.length === 0) {
|
||||
empty.value = true
|
||||
@@ -97,7 +106,7 @@ async function onSelect(answer: string) {
|
||||
duration_seconds: consumeDurationSeconds(),
|
||||
}
|
||||
|
||||
const result = await submitQuizAnswer('daily', payload)
|
||||
const result = await submitQuizAnswer('daily', payload, track.value)
|
||||
if (result.ok && result.is_correct !== undefined) {
|
||||
isCorrect.value = result.is_correct
|
||||
} else {
|
||||
@@ -132,7 +141,7 @@ const accuracy = () => {
|
||||
|
||||
<template>
|
||||
<div class="page quiz-page">
|
||||
<h1 class="page-title">每日训练</h1>
|
||||
<h1 class="page-title">{{ pageTitle }}</h1>
|
||||
|
||||
<p v-if="restored && !loading && !finished && !empty" class="quiz-hint">
|
||||
已恢复上次进度
|
||||
@@ -141,8 +150,10 @@ const accuracy = () => {
|
||||
<p v-if="loading" class="quiz-muted">加载中…</p>
|
||||
|
||||
<div v-else-if="empty" class="quiz-empty">
|
||||
<p class="quiz-muted">词库至少需 4 个单词</p>
|
||||
<router-link to="/translate" class="quiz-link">去添加</router-link>
|
||||
<p v-if="track === 'book'" class="quiz-muted">请先在首页选择词书,或今日暂无待练词目</p>
|
||||
<p v-else class="quiz-muted">积累词库至少需 4 个单词</p>
|
||||
<router-link v-if="track === 'book'" to="/" class="quiz-link">回首页选书</router-link>
|
||||
<router-link v-else to="/translate" class="quiz-link">去添加</router-link>
|
||||
</div>
|
||||
|
||||
<div v-else-if="finished" class="quiz-done">
|
||||
|
||||
@@ -1,86 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api, type QuizStats } from '../api/request'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { api, type ActiveBookState, type QuizStats, type QuizTrack } from '../api/request'
|
||||
|
||||
const stats = ref<QuizStats | null>(null)
|
||||
type HomeMode = 'accumulation' | 'book'
|
||||
|
||||
const HOME_MODE_KEY = 'wordloop_home_mode'
|
||||
|
||||
const mode = ref<HomeMode>('accumulation')
|
||||
const accStats = ref<QuizStats | null>(null)
|
||||
const bookStats = ref<QuizStats | null>(null)
|
||||
const activeBook = ref<ActiveBookState | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
const activeBookTitle = computed(() => activeBook.value?.book?.title ?? '未设置')
|
||||
|
||||
function loadMode() {
|
||||
const saved = localStorage.getItem(HOME_MODE_KEY)
|
||||
if (saved === 'book' || saved === 'accumulation') mode.value = saved
|
||||
}
|
||||
|
||||
function switchMode(next: HomeMode) {
|
||||
mode.value = next
|
||||
localStorage.setItem(HOME_MODE_KEY, next)
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.quizStats()
|
||||
stats.value = data
|
||||
const [accRes, bookRes, activeRes] = await Promise.all([
|
||||
api.quizStats('accumulation'),
|
||||
api.quizStats('book'),
|
||||
api.getActiveBook(),
|
||||
])
|
||||
accStats.value = accRes.data
|
||||
bookStats.value = bookRes.data
|
||||
activeBook.value = activeRes.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function quizLink(track: QuizTrack) {
|
||||
return { path: '/quiz', query: { track } }
|
||||
}
|
||||
|
||||
function spellLink(track: QuizTrack) {
|
||||
return { path: '/spell', query: { track } }
|
||||
}
|
||||
|
||||
function wordsLink(bookId?: number) {
|
||||
return bookId ? { path: '/words', query: { book_id: String(bookId) } } : { path: '/words' }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMode()
|
||||
loadAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<h1 class="page-title">WordLoop</h1>
|
||||
<p v-if="loading" style="color: var(--muted)">加载中...</p>
|
||||
<template v-else-if="stats">
|
||||
<div class="card highlight-card">
|
||||
<div class="highlight-title">今日复习</div>
|
||||
|
||||
<div class="mode-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="mode-tab"
|
||||
:class="{ active: mode === 'accumulation' }"
|
||||
@click="switchMode('accumulation')"
|
||||
>
|
||||
日常积累
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mode-tab"
|
||||
:class="{ active: mode === 'book' }"
|
||||
@click="switchMode('book')"
|
||||
>
|
||||
词书练习
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="muted">加载中...</p>
|
||||
|
||||
<template v-else-if="mode === 'accumulation' && accStats">
|
||||
<div class="acc-toolbar">
|
||||
<p class="mode-desc">翻译加词 · 自由积累 · 独立记忆曲线</p>
|
||||
<router-link to="/accumulation-settings" class="btn btn-outline settings-link">学习设置</router-link>
|
||||
</div>
|
||||
<div class="card highlight-card acc-card">
|
||||
<div class="highlight-title">今日积累练习</div>
|
||||
<div class="highlight-value">
|
||||
{{ stats.today_completed }} / {{ stats.daily_target }}
|
||||
{{ accStats.today_completed }} / {{ accStats.daily_target }}
|
||||
</div>
|
||||
<div class="highlight-sub">
|
||||
正确率 {{ stats.today_accuracy }}% · 连续学习 {{ stats.streak_days }} 天
|
||||
词库 {{ accStats.total_words }} · 掌握 {{ accStats.mastered_count }} · 正确率
|
||||
{{ accStats.today_accuracy }}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.total_words }}</div>
|
||||
<div class="stat-label">总单词数</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.mastered_count }}</div>
|
||||
<div class="stat-label">已掌握</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.learning_count }}</div>
|
||||
<div class="stat-label">学习中</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.weak_count }}</div>
|
||||
<div class="stat-label">易错词</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.new_count }}</div>
|
||||
<div class="stat-label">新词</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.today_accuracy }}%</div>
|
||||
<div class="stat-label">今日正确率</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quick-actions">
|
||||
<router-link to="/words" class="btn btn-outline">记忆曲线</router-link>
|
||||
<router-link to="/quiz" class="btn btn-primary">开始每日训练</router-link>
|
||||
<router-link to="/spell" class="btn btn-outline">拼写练习</router-link>
|
||||
<router-link to="/coach" class="btn btn-outline">记忆对话</router-link>
|
||||
<router-link :to="wordsLink()" class="btn btn-outline">记忆曲线</router-link>
|
||||
<router-link :to="quizLink('accumulation')" class="btn btn-primary">每日训练</router-link>
|
||||
<router-link :to="spellLink('accumulation')" class="btn btn-outline">拼写练习</router-link>
|
||||
<router-link :to="{ path: '/coach', query: { track: 'accumulation' } }" class="btn btn-outline">
|
||||
记忆对话
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="mode === 'book'">
|
||||
<div class="book-toolbar">
|
||||
<p class="mode-desc">按单元顺序 · 与日常积累完全分开</p>
|
||||
<router-link to="/book-settings" class="btn btn-outline settings-link">词书设置</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="activeBook?.book && bookStats" class="card highlight-card book-card">
|
||||
<div class="highlight-title">{{ activeBookTitle }}</div>
|
||||
<div class="highlight-value">
|
||||
{{ bookStats.today_completed }} / {{ bookStats.daily_target }}
|
||||
</div>
|
||||
<div class="highlight-sub">
|
||||
已学 {{ activeBook.progress?.introduced ?? 0 }} / {{ activeBook.progress?.total ?? 0 }}
|
||||
· 掌握 {{ bookStats.mastered_count }}
|
||||
· 待解锁 {{ activeBook.progress?.locked ?? 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="card empty-book-card">
|
||||
<p>尚未选择词书</p>
|
||||
<router-link to="/book-settings" class="btn btn-primary">去词书设置</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="activeBook?.book && bookStats" class="quick-actions">
|
||||
<router-link :to="wordsLink(activeBook.book.id)" class="btn btn-outline">记忆曲线</router-link>
|
||||
<router-link :to="quizLink('book')" class="btn btn-primary">每日训练</router-link>
|
||||
<router-link :to="spellLink('book')" class="btn btn-outline">拼写练习</router-link>
|
||||
<router-link :to="{ path: '/coach', query: { track: 'book' } }" class="btn btn-outline">
|
||||
记忆对话
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.highlight-card {
|
||||
background: linear-gradient(135deg, #4f6ef7, #6b8cff);
|
||||
color: #fff;
|
||||
.mode-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 4px;
|
||||
background: var(--bg-soft, #f3f4f6);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.mode-tab {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
}
|
||||
.mode-tab.active {
|
||||
background: #fff;
|
||||
color: var(--text, #111);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.mode-desc {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.muted { color: var(--muted); }
|
||||
.acc-toolbar,
|
||||
.book-toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.acc-toolbar .mode-desc,
|
||||
.book-toolbar .mode-desc { margin: 0; flex: 1; }
|
||||
.settings-link {
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.highlight-card {
|
||||
color: #fff;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.acc-card {
|
||||
background: linear-gradient(135deg, #4f6ef7, #6b8cff);
|
||||
}
|
||||
.book-card {
|
||||
background: linear-gradient(135deg, #0d9488, #2dd4bf);
|
||||
}
|
||||
.empty-book-card {
|
||||
text-align: center;
|
||||
padding: 24px 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.empty-book-card p {
|
||||
margin: 0 0 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.highlight-title { font-size: 14px; opacity: 0.9; }
|
||||
.highlight-value { font-size: 36px; font-weight: 800; margin: 8px 0; }
|
||||
.highlight-sub { font-size: 13px; opacity: 0.85; }
|
||||
.highlight-value { font-size: 32px; font-weight: 800; margin: 8px 0; }
|
||||
.highlight-sub { font-size: 13px; opacity: 0.9; line-height: 1.5; }
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.quick-actions .btn { text-decoration: none; }
|
||||
.quick-actions .btn { text-decoration: none; text-align: center; }
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import {
|
||||
api,
|
||||
type CoachTurnResponse,
|
||||
type CoachWordBrief,
|
||||
type MemoryToken,
|
||||
type QuizTrack,
|
||||
} from '../api/request'
|
||||
import { useQuizTimer } from '../composables/useQuizTimer'
|
||||
|
||||
@@ -13,6 +15,14 @@ interface ChatLine {
|
||||
content: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const track = computed<QuizTrack>(() =>
|
||||
route.query.track === 'book' ? 'book' : 'accumulation'
|
||||
)
|
||||
const pageTitle = computed(() =>
|
||||
track.value === 'book' ? '词书记忆对话' : '积累记忆对话'
|
||||
)
|
||||
|
||||
const { startQuestionTimer, consumeDurationSeconds } = useQuizTimer()
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -136,7 +146,7 @@ function nextWord() {
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.coachSession()
|
||||
const { data } = await api.coachSession(track.value)
|
||||
words.value = data.words
|
||||
empty.value = data.total === 0
|
||||
if (!empty.value) await startCurrentWord()
|
||||
@@ -149,7 +159,7 @@ onMounted(async () => {
|
||||
<template>
|
||||
<div class="page coach-page">
|
||||
<header class="coach-header">
|
||||
<h1 class="page-title">记忆对话</h1>
|
||||
<h1 class="page-title">{{ pageTitle }}</h1>
|
||||
<span v-if="!empty && !sessionDone" class="coach-count">
|
||||
{{ wordIndex + 1 }} / {{ words.length }}
|
||||
</span>
|
||||
|
||||
@@ -1,62 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api, type Settings, type UserProfile } from '../api/request'
|
||||
import { api, type UserProfile } from '../api/request'
|
||||
import { clearAuth } from '../utils/auth'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const settings = ref<Settings>({
|
||||
daily_target: 20,
|
||||
master_required_count: 3,
|
||||
weak_wrong_threshold: 3,
|
||||
})
|
||||
const profile = ref<UserProfile>({
|
||||
id: 0,
|
||||
username: '',
|
||||
created_at: '',
|
||||
})
|
||||
|
||||
const settingsMessage = ref('')
|
||||
const loading = ref(false)
|
||||
const usernameInitial = computed(() => (profile.value.username ? profile.value.username[0].toUpperCase() : '?'))
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
const detail = (error as { response?: { data?: { detail?: unknown } } })?.response?.data?.detail
|
||||
return typeof detail === 'string' && detail.trim() ? detail : fallback
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [{ data: settingsData }, { data: profileData }] = await Promise.all([
|
||||
api.getSettings(),
|
||||
api.me(),
|
||||
])
|
||||
settings.value = settingsData
|
||||
const { data: profileData } = await api.me()
|
||||
profile.value = profileData
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function saveSettings() {
|
||||
loading.value = true
|
||||
settingsMessage.value = ''
|
||||
try {
|
||||
const { data } = await api.updateSettings(settings.value)
|
||||
settings.value = data
|
||||
settingsMessage.value = '学习设置已保存 ✅'
|
||||
} catch (error) {
|
||||
settingsMessage.value = getErrorMessage(error, '保存学习设置失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearAuth()
|
||||
window.location.href = '/login'
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -64,11 +35,7 @@ function logout() {
|
||||
<div class="page wechat-page">
|
||||
<div class="header">
|
||||
<h1 class="page-title">设置</h1>
|
||||
<p class="page-subtitle">账号与学习参数</p>
|
||||
</div>
|
||||
|
||||
<div v-if="settingsMessage" :class="['message', settingsMessage.includes('✅') ? 'success' : 'error']">
|
||||
{{ settingsMessage }}
|
||||
<p class="page-subtitle">账号与安全</p>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
@@ -95,42 +62,6 @@ function logout() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-title">学习设置</div>
|
||||
<div class="group">
|
||||
<div class="cell">
|
||||
<div class="cell-left">
|
||||
<span class="cell-title">每日训练数量</span>
|
||||
<span class="cell-desc">1 - 100</span>
|
||||
</div>
|
||||
<input v-model.number="settings.daily_target" type="number" min="1" max="100" class="cell-input" />
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="cell">
|
||||
<div class="cell-left">
|
||||
<span class="cell-title">连续答对几次算掌握</span>
|
||||
<span class="cell-desc">1 - 20</span>
|
||||
</div>
|
||||
<input v-model.number="settings.master_required_count" type="number" min="1" max="20" class="cell-input" />
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="cell">
|
||||
<div class="cell-left">
|
||||
<span class="cell-title">累计错几次进入易错词</span>
|
||||
<span class="cell-desc">1 - 20</span>
|
||||
</div>
|
||||
<input v-model.number="settings.weak_wrong_threshold" type="number" min="1" max="20" class="cell-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions-row">
|
||||
<button class="btn btn-outline subtle-btn" @click="router.push('/settings/username')">修改用户名</button>
|
||||
<button class="btn btn-primary save-btn" :disabled="loading" @click="saveSettings">
|
||||
{{ loading ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button class="logout-row" @click="logout">
|
||||
退出登录
|
||||
</button>
|
||||
@@ -257,48 +188,6 @@ function logout() {
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
||||
.cell-input {
|
||||
width: 92px;
|
||||
height: 32px;
|
||||
border: 1px solid #e4e4e7;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
text-align: right;
|
||||
padding: 0 10px;
|
||||
background: #fafafa;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cell-input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(79, 110, 247, 0.12);
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
width: 88px;
|
||||
min-height: 34px;
|
||||
margin: 0;
|
||||
padding: 7px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.subtle-btn {
|
||||
width: auto;
|
||||
min-height: 34px;
|
||||
padding: 7px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.logout-row {
|
||||
width: 100%;
|
||||
margin-top: 18px;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import SpellCard from '../components/SpellCard.vue'
|
||||
import { api, type QuizQuestion } from '../api/request'
|
||||
import { api, type QuizQuestion, type QuizTrack } from '../api/request'
|
||||
import {
|
||||
clearQuizSession,
|
||||
loadQuizSession,
|
||||
@@ -13,6 +14,14 @@ import { useQuizTimer } from '../composables/useQuizTimer'
|
||||
import { consumePracticeFocusWordId } from '../utils/practiceFocus'
|
||||
import { spellQuestionFromWord } from '../utils/spellQuestion'
|
||||
|
||||
const route = useRoute()
|
||||
const track = computed<QuizTrack>(() =>
|
||||
route.query.track === 'book' ? 'book' : 'accumulation'
|
||||
)
|
||||
const pageTitle = computed(() =>
|
||||
track.value === 'book' ? '词书拼写练习' : '积累拼写练习'
|
||||
)
|
||||
|
||||
const { startQuestionTimer, consumeDurationSeconds } = useQuizTimer()
|
||||
|
||||
const questions = ref<QuizQuestion[]>([])
|
||||
@@ -44,10 +53,15 @@ function buildSnapshot(): QuizSessionSnapshot | null {
|
||||
}
|
||||
}
|
||||
|
||||
const { persist, flushPending } = useQuizAutoSave('spell', buildSnapshot, {
|
||||
getDraftAnswer: () => spellCardRef.value?.getDraft() ?? '',
|
||||
onDraftSubmit: (answer) => submitAnswer(answer),
|
||||
})
|
||||
const { persist, flushPending } = useQuizAutoSave(
|
||||
'spell',
|
||||
buildSnapshot,
|
||||
{
|
||||
getDraftAnswer: () => spellCardRef.value?.getDraft() ?? '',
|
||||
onDraftSubmit: (answer) => submitAnswer(answer),
|
||||
},
|
||||
track.value
|
||||
)
|
||||
|
||||
function applySnapshot(snap: QuizSessionSnapshot) {
|
||||
questions.value = snap.questions
|
||||
@@ -62,14 +76,14 @@ function applySnapshot(snap: QuizSessionSnapshot) {
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const saved = loadQuizSession('spell')
|
||||
const saved = loadQuizSession('spell', track.value)
|
||||
if (saved && !saved.finished) {
|
||||
applySnapshot(saved)
|
||||
restored.value = true
|
||||
await flushPending()
|
||||
} else {
|
||||
clearQuizSession('spell')
|
||||
const { data } = await api.spellQuiz()
|
||||
clearQuizSession('spell', track.value)
|
||||
const { data } = await api.spellQuiz(track.value)
|
||||
questions.value = data.questions
|
||||
const focusId = consumePracticeFocusWordId()
|
||||
if (focusId) {
|
||||
@@ -116,7 +130,7 @@ async function submitAnswer(answer: string) {
|
||||
duration_seconds: consumeDurationSeconds(),
|
||||
}
|
||||
|
||||
const result = await submitQuizAnswer('spell', payload)
|
||||
const result = await submitQuizAnswer('spell', payload, track.value)
|
||||
if (result.ok && result.is_correct !== undefined) {
|
||||
isCorrect.value = result.is_correct
|
||||
} else {
|
||||
@@ -136,7 +150,7 @@ function onSubmit(answer: string) {
|
||||
function nextQuestion() {
|
||||
if (currentIndex.value >= questions.value.length - 1) {
|
||||
finished.value = true
|
||||
clearQuizSession('spell')
|
||||
clearQuizSession('spell', track.value)
|
||||
return
|
||||
}
|
||||
currentIndex.value++
|
||||
@@ -155,7 +169,7 @@ const accuracy = () => {
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<h1 class="page-title">拼写练习</h1>
|
||||
<h1 class="page-title">{{ pageTitle }}</h1>
|
||||
|
||||
<p v-if="restored && !loading && !finished && !empty" class="restore-hint">
|
||||
已恢复上次未完成的训练进度
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import TranslateResultPanel from '../components/TranslateResultPanel.vue'
|
||||
import { api, type TranslateResult } from '../api/request'
|
||||
|
||||
const input = ref('')
|
||||
@@ -7,6 +8,7 @@ const result = ref<TranslateResult | null>(null)
|
||||
const message = ref('')
|
||||
const messageType = ref<'success' | 'error'>('success')
|
||||
const loading = ref(false)
|
||||
const adding = ref(false)
|
||||
|
||||
async function handleTranslate() {
|
||||
if (!input.value.trim()) return
|
||||
@@ -26,12 +28,13 @@ async function handleTranslate() {
|
||||
}
|
||||
|
||||
async function addToLibrary() {
|
||||
if (!result.value) return
|
||||
if (!result.value || adding.value || !result.value.target_text.trim()) return
|
||||
adding.value = true
|
||||
message.value = ''
|
||||
try {
|
||||
await api.createWord({
|
||||
source_text: result.value.source_text,
|
||||
target_text: result.value.target_text,
|
||||
target_text: result.value.target_text.trim(),
|
||||
source_lang: result.value.source_lang,
|
||||
target_lang: result.value.target_lang,
|
||||
phonetic: result.value.phonetic,
|
||||
@@ -44,11 +47,10 @@ async function addToLibrary() {
|
||||
const err = e as { response?: { data?: { detail?: string } } }
|
||||
message.value = err.response?.data?.detail || '添加失败'
|
||||
messageType.value = 'error'
|
||||
} finally {
|
||||
adding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const langLabel = (s: string, t: string) =>
|
||||
s === 'zh' ? '中文 → 英文' : '英文 → 中文'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -67,30 +69,7 @@ const langLabel = (s: string, t: string) =>
|
||||
<div v-if="message" :class="['message', messageType]">{{ message }}</div>
|
||||
|
||||
<div v-if="result" class="card result-card">
|
||||
<div class="lang-dir">{{ langLabel(result.source_lang, result.target_lang) }}</div>
|
||||
<div class="result-row">
|
||||
<span class="label-sm">原文</span>
|
||||
<span>{{ result.source_text }}</span>
|
||||
</div>
|
||||
<div class="result-row main">
|
||||
<span class="label-sm">译文</span>
|
||||
<span>{{ result.target_text }}</span>
|
||||
</div>
|
||||
<div v-if="result.phonetic" class="result-row">
|
||||
<span class="label-sm">音标</span>
|
||||
<span>{{ result.phonetic }}</span>
|
||||
</div>
|
||||
<div v-if="result.example_en" class="result-row">
|
||||
<span class="label-sm">英文例句</span>
|
||||
<span>{{ result.example_en }}</span>
|
||||
</div>
|
||||
<div v-if="result.example_cn" class="result-row">
|
||||
<span class="label-sm">中文例句</span>
|
||||
<span>{{ result.example_cn }}</span>
|
||||
</div>
|
||||
<button class="btn btn-primary" style="margin-top: 14px" @click="addToLibrary">
|
||||
加入单词库
|
||||
</button>
|
||||
<TranslateResultPanel v-model="result" :adding="adding" @add="addToLibrary" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -101,24 +80,7 @@ const langLabel = (s: string, t: string) =>
|
||||
min-height: 80px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.result-card { margin-top: 16px; }
|
||||
.lang-dir {
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.result-row {
|
||||
margin-bottom: 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.result-row.main {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.label-sm {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 2px;
|
||||
.result-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -35,6 +35,17 @@ import {
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const libraryBookId = computed(() => {
|
||||
const raw = route.query.book_id
|
||||
if (raw) {
|
||||
const id = Number(raw)
|
||||
return Number.isFinite(id) && id > 0 ? id : 0
|
||||
}
|
||||
return 0
|
||||
})
|
||||
const libraryTitle = computed(() =>
|
||||
libraryBookId.value > 0 ? '词书记忆曲线' : '积累记忆曲线'
|
||||
)
|
||||
const graphPracticeResults = ref<Record<string, PracticeResult>>(loadPracticeResults())
|
||||
|
||||
const graphSessionComplete = computed(() => {
|
||||
@@ -189,7 +200,10 @@ function goToPageAndScroll(page: number) {
|
||||
async function loadWords() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.listWords(activeTab.value || undefined)
|
||||
const { data } = await api.listWords(
|
||||
activeTab.value || undefined,
|
||||
libraryBookId.value > 0 ? libraryBookId.value : 0
|
||||
)
|
||||
words.value = data
|
||||
clampPage()
|
||||
} finally {
|
||||
@@ -200,7 +214,7 @@ async function loadWords() {
|
||||
async function loadViz() {
|
||||
vizLoading.value = true
|
||||
try {
|
||||
const { data } = await api.memoryViz()
|
||||
const { data } = await api.memoryViz(libraryBookId.value)
|
||||
viz.value = data
|
||||
selectedWord.value = data.words[0] ?? null
|
||||
if (data.words[0]) {
|
||||
@@ -358,6 +372,15 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(libraryBookId, () => {
|
||||
if (activeView.value === 'memory') {
|
||||
viz.value = null
|
||||
loadViz()
|
||||
} else {
|
||||
loadWords()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (activeView.value === 'memory') {
|
||||
refreshGraphPracticeResults()
|
||||
@@ -370,7 +393,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<h1 class="page-title">记忆曲线</h1>
|
||||
<h1 class="page-title">{{ libraryTitle }}</h1>
|
||||
|
||||
<div class="view-tabs">
|
||||
<button
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
|
||||
import { getRuntime } from '../config/runtime'
|
||||
import { getToken } from '../utils/auth'
|
||||
|
||||
const isEmbeddedShell = import.meta.env.MODE === 'ios' || getRuntime().embedded
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
// file/custom scheme 下 pathname 常为 /index.html,必须固定 hash base 为 /
|
||||
history: isEmbeddedShell ? createWebHashHistory('/') : createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/login', name: 'Login', component: () => import('../pages/Login.vue') },
|
||||
{ path: '/register', name: 'Register', component: () => import('../pages/Register.vue') },
|
||||
@@ -27,6 +31,16 @@ const router = createRouter({
|
||||
component: () => import('../pages/MemoryCoach.vue'),
|
||||
},
|
||||
{ path: 'settings', name: 'Settings', component: () => import('../pages/Settings.vue') },
|
||||
{
|
||||
path: 'book-settings',
|
||||
name: 'BookSettings',
|
||||
component: () => import('../pages/BookSettings.vue'),
|
||||
},
|
||||
{
|
||||
path: 'accumulation-settings',
|
||||
name: 'AccumulationSettings',
|
||||
component: () => import('../pages/AccumulationSettings.vue'),
|
||||
},
|
||||
{
|
||||
path: 'settings/username',
|
||||
name: 'SettingsUsername',
|
||||
|
||||
@@ -150,6 +150,19 @@ a {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* iOS 壳:底部原生服务器栏占位,避免与 H5 底栏重叠 */
|
||||
html.wordloop-ios-app {
|
||||
--ios-server-bar: 40px;
|
||||
}
|
||||
|
||||
html.wordloop-ios-app .page {
|
||||
padding-bottom: calc(88px + var(--ios-server-bar));
|
||||
}
|
||||
|
||||
html.wordloop-ios-app .nav-bottom {
|
||||
bottom: var(--ios-server-bar);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -227,3 +240,8 @@ a {
|
||||
background: #d1fae5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.message.warning {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { clearFabPositions } from '../composables/useDraggableFab'
|
||||
|
||||
const TOKEN_KEY = 'token'
|
||||
const REMEMBER_KEY = 'wordloop_remember'
|
||||
const USERNAME_KEY = 'wordloop_username'
|
||||
@@ -34,6 +36,7 @@ export function getToken(): string | null {
|
||||
}
|
||||
|
||||
export function setAuth(token: string, remember: boolean, username?: string) {
|
||||
clearFabPositions()
|
||||
setRememberPreference(remember)
|
||||
clearTokenStores()
|
||||
if (remember) {
|
||||
@@ -48,6 +51,7 @@ export function setAuth(token: string, remember: boolean, username?: string) {
|
||||
|
||||
/** 退出登录:清除凭证,保留用户名与「记住」偏好供下次登录 */
|
||||
export function clearAuth() {
|
||||
clearFabPositions()
|
||||
clearTokenStores()
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/request.ts","./src/composables/usegraphfilter.ts","./src/composables/usequizsession.ts","./src/composables/usequiztimer.ts","./src/composables/usetranslatefab.ts","./src/router/index.ts","./src/utils/auth.ts","./src/utils/buildpracticequestion.ts","./src/utils/practicefocus.ts","./src/utils/practicegraphdisplay.ts","./src/utils/practicegraphsession.ts","./src/utils/spellquestion.ts","./src/app.vue","./src/components/memorycurvechart.vue","./src/components/quizcard.vue","./src/components/spellcard.vue","./src/components/trainfab.vue","./src/components/translatefab.vue","./src/components/wordcard.vue","./src/components/wordgraphcanvas.vue","./src/layouts/mainlayout.vue","./src/pages/dailyquiz.vue","./src/pages/dashboard.vue","./src/pages/graphpractice.vue","./src/pages/login.vue","./src/pages/memorycoach.vue","./src/pages/passwordsettings.vue","./src/pages/register.vue","./src/pages/settings.vue","./src/pages/spellquiz.vue","./src/pages/translate.vue","./src/pages/usernamesettings.vue","./src/pages/wordlibrary.vue"],"version":"5.6.3"}
|
||||
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/request.ts","./src/composables/usedraggablefab.ts","./src/composables/usegraphfilter.ts","./src/composables/usequizsession.ts","./src/composables/usequiztimer.ts","./src/composables/usetranslatefab.ts","./src/config/runtime.ts","./src/router/index.ts","./src/utils/auth.ts","./src/utils/buildpracticequestion.ts","./src/utils/practicefocus.ts","./src/utils/practicegraphdisplay.ts","./src/utils/practicegraphsession.ts","./src/utils/spellquestion.ts","./src/app.vue","./src/components/memorycurvechart.vue","./src/components/quizcard.vue","./src/components/spellcard.vue","./src/components/trainfab.vue","./src/components/translatefab.vue","./src/components/translateresultpanel.vue","./src/components/wordcard.vue","./src/components/wordgraphcanvas.vue","./src/layouts/mainlayout.vue","./src/pages/accumulationsettings.vue","./src/pages/booksettings.vue","./src/pages/dailyquiz.vue","./src/pages/dashboard.vue","./src/pages/graphpractice.vue","./src/pages/login.vue","./src/pages/memorycoach.vue","./src/pages/passwordsettings.vue","./src/pages/register.vue","./src/pages/settings.vue","./src/pages/spellquiz.vue","./src/pages/translate.vue","./src/pages/usernamesettings.vue","./src/pages/wordlibrary.vue"],"version":"5.6.3"}
|
||||
+64
-4
@@ -1,8 +1,68 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, type Plugin } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
/** WKWebView 用 file:// 加载时,crossorigin 会导致脚本无法执行、页面空白 */
|
||||
function stripCrossOriginForIOS(): Plugin {
|
||||
return {
|
||||
name: 'strip-crossorigin-ios',
|
||||
apply: 'build',
|
||||
enforce: 'post',
|
||||
transformIndexHtml(html) {
|
||||
return html.replace(/\s+crossorigin(="[^"]*")?/g, '')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** iOS 内嵌启动配置:在模块脚本之前注入,避免 file:// 下懒加载 chunk 失败 */
|
||||
function injectIOSRuntimeBootstrap(): Plugin {
|
||||
return {
|
||||
name: 'inject-ios-runtime-bootstrap',
|
||||
apply: 'build',
|
||||
transformIndexHtml(html) {
|
||||
const bootstrap = `<script>
|
||||
window.__WORDLOOP_RUNTIME__ = {
|
||||
embedded: true,
|
||||
iosApp: true,
|
||||
apiBase: 'https://w.tkmind.cn/api'
|
||||
};
|
||||
(function () {
|
||||
if (!location.hash || location.hash === '#') {
|
||||
var base = location.href.split('#')[0];
|
||||
location.replace(base + '#/');
|
||||
}
|
||||
})();
|
||||
</script>`
|
||||
if (html.includes('__WORDLOOP_RUNTIME__')) return html
|
||||
const withoutModule = html
|
||||
.replace(/\s+crossorigin(="[^"]*")?/g, '')
|
||||
.replace('<script type="module"', '<script')
|
||||
.replace(/\.\/assets\//g, 'wordloop://app/assets/')
|
||||
return withoutModule.replace('<script src=', `${bootstrap}\n <script src=`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: mode === 'ios' ? './' : '/',
|
||||
plugins: [
|
||||
vue(),
|
||||
...(mode === 'ios' ? [stripCrossOriginForIOS(), injectIOSRuntimeBootstrap()] : []),
|
||||
],
|
||||
build: mode === 'ios'
|
||||
? {
|
||||
modulePreload: false,
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// WKWebView file:// 下 type=module 常无法执行,改为 IIFE 普通脚本
|
||||
format: 'iife',
|
||||
inlineDynamicImports: true,
|
||||
entryFileNames: 'assets/app.js',
|
||||
assetFileNames: 'assets/app.[ext]',
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
server: {
|
||||
port: 18003,
|
||||
proxy: {
|
||||
@@ -12,4 +72,4 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user