refactor(web): migrate to TypeScript + standard Vue3 structure

This commit is contained in:
2026-02-26 12:29:25 +08:00
parent 52f691f02e
commit e2a9ebc7b7
28 changed files with 814 additions and 88 deletions

View File

@@ -1,6 +0,0 @@
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
export function registerPlugins(app) {
app.use(ElementPlus)
}

View File

@@ -0,0 +1,9 @@
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import '../styles/index.scss'
import type { App } from 'vue'
export function registerPlugins(app: App) {
app.use(ElementPlus)
}

View File

@@ -0,0 +1,43 @@
<template>
<div class="metric-card">
<div class="metric-label">{{ label }}</div>
<div class="metric-value">{{ value }}</div>
<div v-if="icon" class="metric-icon">
<span>{{ icon }}</span>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
label: string
value: number | string
icon?: string
}>()
</script>
<style scoped>
.metric-card {
position: relative;
background: #fff;
border: 1px solid #eceef3;
border-radius: 14px;
padding: 18px;
min-height: 86px;
}
.metric-label { color: #6b7280; font-size: 14px; }
.metric-value { margin-top: 8px; font-size: 34px; font-weight: 700; color: #111827; }
.metric-icon {
position: absolute;
right: 14px;
top: 14px;
width: 32px;
height: 32px;
border-radius: 10px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
color: #111827;
}
</style>

7
memora-web/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@@ -1,36 +0,0 @@
import axios from 'axios'
export const api = axios.create({
baseURL: '/api',
timeout: 15000
})
export async function addWord(word) {
const res = await api.post('/words', { word })
return res.data
}
export async function getWords({ limit = 20, offset = 0 } = {}) {
const res = await api.get('/words', { params: { limit, offset } })
return res.data
}
export async function getReviewWords({ mode = 'spelling', limit = 10 } = {}) {
const res = await api.get('/review', { params: { mode, limit } })
return res.data
}
export async function submitReview({ recordId, answer, mode }) {
const res = await api.post('/review', { record_id: recordId, answer, mode })
return res.data
}
export async function getStatistics() {
const res = await api.get('/stats')
return res.data
}
export function audioUrl({ word, type = 'uk' }) {
const q = new URLSearchParams({ word, type })
return `/api/audio?${q.toString()}`
}

View File

@@ -0,0 +1,4 @@
export * from './types'
export * from './stats'
export * from './words'
export * from './review'

View File

@@ -0,0 +1,16 @@
import { http } from '../http'
import type { MemoryRecord, ReviewMode, ReviewResult } from './types'
export async function getReviewWords(params: { mode?: ReviewMode; limit?: number } = {}) {
const res = await http.get<{ data: MemoryRecord[] }>('/review', { params })
return res.data
}
export async function submitReview(payload: { recordId: number; answer: string; mode: ReviewMode }) {
const res = await http.post<{ data: ReviewResult }>('/review', {
record_id: payload.recordId,
answer: payload.answer,
mode: payload.mode
})
return res.data
}

View File

@@ -0,0 +1,7 @@
import { http } from '../http'
import type { Stats } from './types'
export async function getStatistics() {
const res = await http.get<{ data: Stats }>('/stats')
return res.data
}

View File

@@ -0,0 +1,35 @@
export interface Word {
id: number
word: string
phonetic_uk?: string
phonetic_us?: string
audio_uk?: string
audio_us?: string
part_of_speech?: string
definition?: string
}
export interface MemoryRecord {
id: number
word_id: number
correct_count: number
total_count: number
mastery_level: number
word?: Word
}
export interface Stats {
total_words: number
mastered_words: number
need_review: number
today_reviewed: number
}
export type ReviewMode = 'spelling' | 'en2cn' | 'cn2en'
export interface ReviewResult {
word: Word
correct: boolean
answer: string
correct_ans?: string
}

View File

@@ -0,0 +1,17 @@
import { http } from '../http'
import type { Word } from './types'
export async function addWord(word: string) {
const res = await http.post<{ data: Word }>('/words', { word })
return res.data
}
export async function getWords(params: { limit?: number; offset?: number } = {}) {
const res = await http.get<{ data: Word[]; total: number }>('/words', { params })
return res.data
}
export function audioUrl(word: string, type: 'uk' | 'us' = 'uk') {
const q = new URLSearchParams({ word, type })
return `/api/audio?${q.toString()}`
}

View File

@@ -0,0 +1,14 @@
import axios from 'axios'
export const http = axios.create({
baseURL: '/api',
timeout: 15000
})
http.interceptors.response.use(
(res) => res,
(err) => {
// 统一错误抛出
return Promise.reject(err)
}
)

View File

@@ -0,0 +1,18 @@
:root {
--memora-bg: #f4f5fb;
--memora-border: #eceef3;
--memora-text: #111827;
--memora-sub: #6b7280;
--memora-primary: #2f7d32;
}
html, body {
height: 100%;
}
body {
margin: 0;
background: var(--memora-bg);
color: var(--memora-text);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
}

View File

@@ -4,10 +4,10 @@
<div class="sub">欢迎回来继续你的学习之旅</div>
<el-row :gutter="16" class="cards">
<el-col :span="6"><MetricCard label="今日复习数" :value="stats.today_reviewed ?? 0" /></el-col>
<el-col :span="6"><MetricCard label="待复习数" :value="stats.need_review ?? 0" /></el-col>
<el-col :span="6"><MetricCard label="已掌握" :value="stats.mastered_words ?? 0" /></el-col>
<el-col :span="6"><MetricCard label="总词汇" :value="stats.total_words ?? 0" /></el-col>
<el-col :span="6"><MetricCard label="今日复习数" :value="stats.today_reviewed ?? 0" icon="🎓" /></el-col>
<el-col :span="6"><MetricCard label="待复习数" :value="stats.need_review ?? 0" icon="📖" /></el-col>
<el-col :span="6"><MetricCard label="已掌握" :value="stats.mastered_words ?? 0" icon="🎯" /></el-col>
<el-col :span="6"><MetricCard label="总词汇" :value="stats.total_words ?? 0" icon="📚" /></el-col>
</el-row>
<div class="actions">
@@ -17,11 +17,19 @@
</div>
</template>
<script setup>
import { defineComponent, h, onMounted, ref } from 'vue'
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { getStatistics } from '../services/api'
import MetricCard from '../components/base/MetricCard.vue'
const stats = ref({})
import type { Stats } from '../services/api'
const stats = ref<Stats>({
total_words: 0,
mastered_words: 0,
need_review: 0,
today_reviewed: 0
})
async function refresh() {
const res = await getStatistics()
@@ -30,15 +38,7 @@ async function refresh() {
onMounted(() => refresh().catch(console.error))
const MetricCard = defineComponent({
props: { label: String, value: Number },
setup(props) {
return () => h('div', { class: 'metric-card' }, [
h('div', { class: 'metric-label' }, props.label),
h('div', { class: 'metric-value' }, String(props.value ?? 0))
])
}
})
</script>
<style scoped>
@@ -46,12 +46,4 @@ const MetricCard = defineComponent({
.sub { margin-top: 4px; color: #6b7280; }
.cards { margin-top: 20px; }
.actions { margin-top: 18px; display: flex; gap: 10px; }
.metric-card {
background: #fff;
border: 1px solid #eceef3;
border-radius: 14px;
padding: 18px;
}
.metric-label { color: #6b7280; font-size: 14px; }
.metric-value { margin-top: 8px; font-size: 34px; font-weight: 700; color: #111827; }
</style>

View File

@@ -39,14 +39,16 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { ref } from 'vue'
import { addWord } from '../services/api'
const word = ref('')
const loading = ref(false)
const error = ref('')
const saved = ref(null)
import type { Word } from '../services/api'
const saved = ref<Word | null>(null)
async function submit() {
const w = word.value.trim()
@@ -59,7 +61,7 @@ async function submit() {
// 后端建议返回 { data: Word }
saved.value = res.data ?? res
word.value = ''
} catch (e) {
} catch (e: any) {
error.value = e?.response?.data?.error || e?.message || '请求失败'
} finally {
loading.value = false

View File

@@ -22,10 +22,10 @@
<el-button @click="play">播放读音(uk)</el-button>
</template>
<template v-else-if="mode === 'en2cn'">
<div class="q">{{ record.word.word }}</div>
<div class="q">{{ record.word?.word }}</div>
</template>
<template v-else>
<div class="q">{{ record.word.definition }}</div>
<div class="q">{{ record.word?.definition }}</div>
</template>
</el-card>
@@ -46,15 +46,17 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { audioUrl, getReviewWords, submitReview } from '../services/api'
const mode = ref('spelling')
const record = ref(null) // MemoryRecord (含 word)
import type { MemoryRecord, ReviewMode, ReviewResult } from '../services/api'
const mode = ref<ReviewMode>('spelling')
const record = ref<MemoryRecord | null>(null) // MemoryRecord (含 word)
const answer = ref('')
const loading = ref(false)
const result = ref(null)
const result = ref<ReviewResult | null>(null)
const modeHint = computed(() => {
if (mode.value === 'spelling') return '听读音,拼写单词'
@@ -65,14 +67,14 @@ const modeHint = computed(() => {
async function loadOne() {
result.value = null
answer.value = ''
const res = await getReviewWords({ mode: mode.value, limit: 1 })
const arr = res.data ?? res
record.value = Array.isArray(arr) && arr.length ? arr[0] : null
const res = await getReviewWords({ mode: mode.value, limit: 1 })
const arr = (res as any).data ?? (res as any)
record.value = Array.isArray(arr) && arr.length ? (arr[0] as MemoryRecord) : null
}
function play() {
if (!record.value?.word?.word) return
const a = new Audio(audioUrl({ word: record.value.word.word, type: 'uk' }))
const a = new Audio(audioUrl(record.value.word.word, 'uk'))
a.play()
}
@@ -82,7 +84,7 @@ async function submit() {
loading.value = true
try {
const res = await submitReview({ recordId: record.value.id, answer: answer.value, mode: mode.value })
result.value = res.data ?? res
result.value = (res as any).data ?? (res as any)
} finally {
loading.value = false
}

View File

@@ -9,5 +9,5 @@
</el-card>
</template>
<script setup>
<script setup lang="ts">
</script>

View File

@@ -18,11 +18,18 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { getStatistics } from '../services/api'
const stats = ref({})
import type { Stats } from '../services/api'
const stats = ref<Stats>({
total_words: 0,
mastered_words: 0,
need_review: 0,
today_reviewed: 0
})
async function refresh() {
const res = await getStatistics()
stats.value = res.data ?? res

View File

@@ -10,12 +10,14 @@
</el-card>
</template>
<script setup>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { getWords } from '../services/api'
const kw = ref('')
const rows = ref([])
import type { Word } from '../services/api'
const rows = ref<Word[]>([])
const filtered = computed(() => {
const q = kw.value.trim().toLowerCase()