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:
john
2026-06-06 21:09:27 +08:00
parent 2fae90b597
commit e76fa586f1
76 changed files with 47488 additions and 445 deletions
+1
View File
@@ -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
View File
@@ -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
+21 -6
View File
@@ -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);
}
+25 -61
View File
@@ -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>
+136
View File
@@ -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 }
}
+58 -35
View File
@@ -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) }
}
+46
View File
@@ -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
View File
@@ -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()
}
+250
View File
@@ -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>
+368
View File
@@ -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>
+21 -10
View File
@@ -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">
+186 -51
View File
@@ -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>
+12 -2
View File
@@ -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>
+4 -115
View File
@@ -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;
+26 -12
View File
@@ -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">
已恢复上次未完成的训练进度
+10 -48
View File
@@ -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>
+26 -3
View File
@@ -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
+16 -2
View File
@@ -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',
+18
View File
@@ -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;
}
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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({
},
},
},
})
}))