refactor(web): restructure Vue3 app layout
This commit is contained in:
7
memora-web/Dockerfile
Normal file
7
memora-web/Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
|
||||
13
memora-web/index.html
Normal file
13
memora-web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Memora 背单词</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uview-plus@3.2.0/index.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1745
memora-web/package-lock.json
generated
Normal file
1745
memora-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
memora-web/package.json
Normal file
21
memora-web/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "memora-web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.8",
|
||||
"element-plus": "^2.7.2",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"vite": "^5.1.6"
|
||||
}
|
||||
}
|
||||
65
memora-web/src/App.vue
Normal file
65
memora-web/src/App.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<el-container class="layout">
|
||||
<el-aside width="220px" class="sidebar">
|
||||
<div class="logo">Memora</div>
|
||||
<el-menu
|
||||
:default-active="$route.path"
|
||||
class="menu"
|
||||
@select="go"
|
||||
>
|
||||
<el-menu-item index="/">仪表盘</el-menu-item>
|
||||
<el-menu-item index="/memory">记忆模式</el-menu-item>
|
||||
<el-menu-item index="/review">复习模式</el-menu-item>
|
||||
<el-menu-item index="/words">单词列表</el-menu-item>
|
||||
<el-menu-item index="/settings">设置</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<el-header class="topbar"></el-header>
|
||||
<el-main class="main">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
const go = (path) => router.push(path)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout { min-height: 100vh; background: #f4f5fb; }
|
||||
.sidebar {
|
||||
background: #fff;
|
||||
border-right: 1px solid #eceef3;
|
||||
}
|
||||
.logo {
|
||||
height: 62px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 18px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
.menu {
|
||||
border-right: none;
|
||||
padding: 8px;
|
||||
}
|
||||
:deep(.el-menu-item.is-active) {
|
||||
background: #2f7d32;
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.topbar {
|
||||
height: 62px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #eceef3;
|
||||
}
|
||||
.main {
|
||||
padding: 24px 28px;
|
||||
}
|
||||
</style>
|
||||
11
memora-web/src/app/index.js
Normal file
11
memora-web/src/app/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from '../App.vue'
|
||||
import router from '../router'
|
||||
import { registerPlugins } from './plugins'
|
||||
|
||||
export function bootstrap() {
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
registerPlugins(app)
|
||||
app.mount('#app')
|
||||
}
|
||||
6
memora-web/src/app/plugins.js
Normal file
6
memora-web/src/app/plugins.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
|
||||
export function registerPlugins(app) {
|
||||
app.use(ElementPlus)
|
||||
}
|
||||
15
memora-web/src/app/routes.js
Normal file
15
memora-web/src/app/routes.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import Dashboard from '../views/Home.vue'
|
||||
import Memory from '../views/Memory.vue'
|
||||
import Review from '../views/Review.vue'
|
||||
import Statistics from '../views/Statistics.vue'
|
||||
import Words from '../views/Words.vue'
|
||||
import Settings from '../views/Settings.vue'
|
||||
|
||||
export const routes = [
|
||||
{ path: '/', name: 'dashboard', component: Dashboard },
|
||||
{ path: '/memory', name: 'memory', component: Memory },
|
||||
{ path: '/review', name: 'review', component: Review },
|
||||
{ path: '/statistics', name: 'statistics', component: Statistics },
|
||||
{ path: '/words', name: 'words', component: Words },
|
||||
{ path: '/settings', name: 'settings', component: Settings }
|
||||
]
|
||||
3
memora-web/src/main.js
Normal file
3
memora-web/src/main.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { bootstrap } from './app'
|
||||
|
||||
bootstrap()
|
||||
9
memora-web/src/router/index.js
Normal file
9
memora-web/src/router/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { routes } from '../app/routes'
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
36
memora-web/src/services/api.js
Normal file
36
memora-web/src/services/api.js
Normal file
@@ -0,0 +1,36 @@
|
||||
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()}`
|
||||
}
|
||||
57
memora-web/src/views/Home.vue
Normal file
57
memora-web/src/views/Home.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="title">仪表盘</div>
|
||||
<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-row>
|
||||
|
||||
<div class="actions">
|
||||
<el-button type="success" @click="$router.push('/review')">开始复习</el-button>
|
||||
<el-button @click="$router.push('/memory')">添加单词</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineComponent, h, onMounted, ref } from 'vue'
|
||||
import { getStatistics } from '../services/api'
|
||||
|
||||
const stats = ref({})
|
||||
|
||||
async function refresh() {
|
||||
const res = await getStatistics()
|
||||
stats.value = res.data ?? res
|
||||
}
|
||||
|
||||
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>
|
||||
.title { font-size: 30px; font-weight: 700; color: #111827; }
|
||||
.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>
|
||||
75
memora-web/src/views/Memory.vue
Normal file
75
memora-web/src/views/Memory.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="h">
|
||||
<div>
|
||||
<div class="title">记忆模式</div>
|
||||
<div class="sub">输入单词并回车,会调用后端(后端再调用有道)校验并入库</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form @submit.prevent>
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="word"
|
||||
size="large"
|
||||
placeholder="输入英文单词,如: example"
|
||||
@keyup.enter="submit"
|
||||
:disabled="loading"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="success" size="large" :loading="loading" @click="submit">确认</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert v-if="error" type="error" :title="error" show-icon />
|
||||
|
||||
<el-descriptions v-if="saved" :column="1" border style="margin-top:16px">
|
||||
<el-descriptions-item label="单词">{{ saved.word }}</el-descriptions-item>
|
||||
<el-descriptions-item label="词性">{{ saved.part_of_speech }}</el-descriptions-item>
|
||||
<el-descriptions-item label="释义">{{ saved.definition }}</el-descriptions-item>
|
||||
<el-descriptions-item label="英音">{{ saved.phonetic_uk }}</el-descriptions-item>
|
||||
<el-descriptions-item label="美音">{{ saved.phonetic_us }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { addWord } from '../services/api'
|
||||
|
||||
const word = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const saved = ref(null)
|
||||
|
||||
async function submit() {
|
||||
const w = word.value.trim()
|
||||
if (!w) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
saved.value = null
|
||||
try {
|
||||
const res = await addWord(w)
|
||||
// 后端建议返回 { data: Word }
|
||||
saved.value = res.data ?? res
|
||||
word.value = ''
|
||||
} catch (e) {
|
||||
error.value = e?.response?.data?.error || e?.message || '请求失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wrap{padding:24px;}
|
||||
.h{display:flex; align-items:center; justify-content:space-between;}
|
||||
.title{font-size:22px; font-weight:700;}
|
||||
.sub{color:#666; margin-top:4px;}
|
||||
</style>
|
||||
99
memora-web/src/views/Review.vue
Normal file
99
memora-web/src/views/Review.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="h">
|
||||
<div class="title">复习模式</div>
|
||||
<el-select v-model="mode" style="width: 220px" @change="loadOne">
|
||||
<el-option label="听音拼写" value="spelling" />
|
||||
<el-option label="英译中" value="en2cn" />
|
||||
<el-option label="中译英" value="cn2en" />
|
||||
</el-select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-empty v-if="!record" description="暂无可复习的单词" />
|
||||
|
||||
<div v-else>
|
||||
<el-alert v-if="modeHint" type="info" :title="modeHint" show-icon style="margin-bottom:12px" />
|
||||
|
||||
<el-card shadow="never" style="margin-bottom:12px">
|
||||
<template v-if="mode === 'spelling'">
|
||||
<el-button @click="play">播放读音(uk)</el-button>
|
||||
</template>
|
||||
<template v-else-if="mode === 'en2cn'">
|
||||
<div class="q">{{ record.word.word }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="q">{{ record.word.definition }}</div>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<el-input v-model="answer" placeholder="输入答案并回车" @keyup.enter="submit" />
|
||||
<div style="margin-top:12px; display:flex; gap:12px">
|
||||
<el-button type="primary" :loading="loading" @click="submit">提交</el-button>
|
||||
<el-button @click="loadOne" :disabled="loading">换一个</el-button>
|
||||
</div>
|
||||
|
||||
<el-result v-if="result" :icon="result.correct ? 'success' : 'error'" :title="result.correct ? '正确' : '不对'" style="margin-top:16px">
|
||||
<template #sub-title>
|
||||
<div>单词:{{ result.word.word }} / 释义:{{ result.word.definition }}</div>
|
||||
<div v-if="!result.correct">你的答案:{{ result.answer }};正确:{{ result.correct_ans }}</div>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { audioUrl, getReviewWords, submitReview } from '../services/api'
|
||||
|
||||
const mode = ref('spelling')
|
||||
const record = ref(null) // MemoryRecord (含 word)
|
||||
const answer = ref('')
|
||||
const loading = ref(false)
|
||||
const result = ref(null)
|
||||
|
||||
const modeHint = computed(() => {
|
||||
if (mode.value === 'spelling') return '听读音,拼写单词'
|
||||
if (mode.value === 'en2cn') return '看到英文,写中文意思(允许包含匹配)'
|
||||
return '看到中文意思,写英文单词'
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function play() {
|
||||
if (!record.value?.word?.word) return
|
||||
const a = new Audio(audioUrl({ word: record.value.word.word, type: 'uk' }))
|
||||
a.play()
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!record.value) return
|
||||
if (!answer.value.trim()) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await submitReview({ recordId: record.value.id, answer: answer.value, mode: mode.value })
|
||||
result.value = res.data ?? res
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
loadOne().catch(console.error)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wrap{padding:24px;}
|
||||
.h{display:flex; align-items:center; justify-content:space-between; gap:12px;}
|
||||
.title{font-size:22px; font-weight:700;}
|
||||
.q{font-size:24px; font-weight:700;}
|
||||
</style>
|
||||
13
memora-web/src/views/Settings.vue
Normal file
13
memora-web/src/views/Settings.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<template #header>设置</template>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="有道 API">已在后端 config.yaml 配置</el-descriptions-item>
|
||||
<el-descriptions-item label="音频缓存目录">memora-api/audio</el-descriptions-item>
|
||||
<el-descriptions-item label="当前前端框架">Vue3 + Element Plus</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
38
memora-web/src/views/Statistics.vue
Normal file
38
memora-web/src/views/Statistics.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="h">
|
||||
<div class="title">统计</div>
|
||||
<el-button @click="refresh">刷新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="6"><el-statistic title="总单词" :value="stats.total_words ?? 0" /></el-col>
|
||||
<el-col :span="6"><el-statistic title="已掌握" :value="stats.mastered_words ?? 0" /></el-col>
|
||||
<el-col :span="6"><el-statistic title="待复习" :value="stats.need_review ?? 0" /></el-col>
|
||||
<el-col :span="6"><el-statistic title="今日复习" :value="stats.today_reviewed ?? 0" /></el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getStatistics } from '../services/api'
|
||||
|
||||
const stats = ref({})
|
||||
async function refresh() {
|
||||
const res = await getStatistics()
|
||||
stats.value = res.data ?? res
|
||||
}
|
||||
|
||||
onMounted(() => refresh().catch(console.error))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wrap{padding:24px;}
|
||||
.h{display:flex; align-items:center; justify-content:space-between;}
|
||||
.title{font-size:22px; font-weight:700;}
|
||||
</style>
|
||||
30
memora-web/src/views/Words.vue
Normal file
30
memora-web/src/views/Words.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<template #header>单词列表</template>
|
||||
<el-input v-model="kw" placeholder="搜索单词" style="max-width:280px;margin-bottom:12px" />
|
||||
<el-table :data="filtered" border>
|
||||
<el-table-column prop="word" label="单词" width="180" />
|
||||
<el-table-column prop="part_of_speech" label="词性" width="120" />
|
||||
<el-table-column prop="definition" label="释义" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { getWords } from '../services/api'
|
||||
|
||||
const kw = ref('')
|
||||
const rows = ref([])
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = kw.value.trim().toLowerCase()
|
||||
if (!q) return rows.value
|
||||
return rows.value.filter(r => (r.word || '').toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await getWords({ limit: 200, offset: 0 })
|
||||
rows.value = res.data || []
|
||||
})
|
||||
</script>
|
||||
15
memora-web/vite.config.js
Normal file
15
memora-web/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user