refactor(web): migrate to TypeScript + standard Vue3 structure
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
|
||||
export function registerPlugins(app) {
|
||||
app.use(ElementPlus)
|
||||
}
|
||||
9
memora-web/src/app/plugins.ts
Normal file
9
memora-web/src/app/plugins.ts
Normal 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)
|
||||
}
|
||||
43
memora-web/src/components/base/MetricCard.vue
Normal file
43
memora-web/src/components/base/MetricCard.vue
Normal 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
7
memora-web/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -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()}`
|
||||
}
|
||||
4
memora-web/src/services/api/index.ts
Normal file
4
memora-web/src/services/api/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './types'
|
||||
export * from './stats'
|
||||
export * from './words'
|
||||
export * from './review'
|
||||
16
memora-web/src/services/api/review.ts
Normal file
16
memora-web/src/services/api/review.ts
Normal 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
|
||||
}
|
||||
7
memora-web/src/services/api/stats.ts
Normal file
7
memora-web/src/services/api/stats.ts
Normal 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
|
||||
}
|
||||
35
memora-web/src/services/api/types.ts
Normal file
35
memora-web/src/services/api/types.ts
Normal 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
|
||||
}
|
||||
17
memora-web/src/services/api/words.ts
Normal file
17
memora-web/src/services/api/words.ts
Normal 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()}`
|
||||
}
|
||||
14
memora-web/src/services/http.ts
Normal file
14
memora-web/src/services/http.ts
Normal 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)
|
||||
}
|
||||
)
|
||||
18
memora-web/src/styles/index.scss
Normal file
18
memora-web/src/styles/index.scss
Normal 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";
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user