feat: 单词书页面+优化

This commit is contained in:
阿梦 2026-05-06 15:56:56 +08:00
parent 1c2d429b11
commit d34cb7b5a4
16 changed files with 864 additions and 212 deletions

4
.components.d.ts vendored
View File

@ -13,7 +13,6 @@ declare module 'vue' {
AnalysisLineChart: typeof import('./src/components/analysis/AnalysisLineChart.vue')['default'] AnalysisLineChart: typeof import('./src/components/analysis/AnalysisLineChart.vue')['default']
AnalysisStatCard: typeof import('./src/components/analysis/AnalysisStatCard.vue')['default'] AnalysisStatCard: typeof import('./src/components/analysis/AnalysisStatCard.vue')['default']
AppHeader: typeof import('./src/components/layout/AppHeader.vue')['default'] AppHeader: typeof import('./src/components/layout/AppHeader.vue')['default']
ChoiceAnswerPanel: typeof import('./src/components/answerQuestions/ChoiceAnswerPanel.vue')['default']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDialog: typeof import('element-plus/es')['ElDialog'] ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer'] ElDrawer: typeof import('element-plus/es')['ElDrawer']
@ -25,8 +24,6 @@ declare module 'vue' {
LmStudentTable: typeof import('./src/components/lm/LmStudentTable.vue')['default'] LmStudentTable: typeof import('./src/components/lm/LmStudentTable.vue')['default']
LoadingState: typeof import('./src/components/loading/LoadingState.vue')['default'] LoadingState: typeof import('./src/components/loading/LoadingState.vue')['default']
PageFrame: typeof import('./src/components/layout/PageFrame.vue')['default'] PageFrame: typeof import('./src/components/layout/PageFrame.vue')['default']
QuestionNumberNav: typeof import('./src/components/answerQuestions/QuestionNumberNav.vue')['default']
QuestionStemPanel: typeof import('./src/components/answerQuestions/QuestionStemPanel.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
SubjectAnalysisContent: typeof import('./src/components/analysis/SubjectAnalysisContent.vue')['default'] SubjectAnalysisContent: typeof import('./src/components/analysis/SubjectAnalysisContent.vue')['default']
@ -36,6 +33,5 @@ declare module 'vue' {
TabBar: typeof import('./src/components/layout/TabBar.vue')['default'] TabBar: typeof import('./src/components/layout/TabBar.vue')['default']
TaskCard: typeof import('./src/components/cards/TaskCard.vue')['default'] TaskCard: typeof import('./src/components/cards/TaskCard.vue')['default']
TaskSidebar: typeof import('./src/components/tasks/TaskSidebar.vue')['default'] TaskSidebar: typeof import('./src/components/tasks/TaskSidebar.vue')['default']
UploadAnswerPanel: typeof import('./src/components/answerQuestions/UploadAnswerPanel.vue')['default']
} }
} }

View File

@ -10,7 +10,13 @@
<span class="reward-item"><i class="reward-icon"></i>x20</span> <span class="reward-item"><i class="reward-icon"></i>x20</span>
</div> </div>
</div> </div>
<button class="task-btn" :class="{ 'is-disabled': disabled }">前往</button> <button
class="task-btn lx-btn lx-btn--lg lx-btn--short lx-btn--primary"
type="button"
:disabled="disabled"
>
前往
</button>
</article> </article>
</template> </template>
@ -78,33 +84,6 @@ defineProps<{
} }
.task-btn { .task-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0; flex-shrink: 0;
min-height: 4.125rem;
min-width: 10.5rem;
padding: 0 24px;
border: none;
border-radius: 9999px;
font-size: 1.75rem;
font-weight: 500;
line-height: 1;
color: #fff;
background: #3d78f2;
cursor: pointer;
&:hover {
background: #356de0;
}
&.is-disabled {
background: #aec4f2;
cursor: not-allowed;
&:hover {
background: #aec4f2;
}
}
} }
</style> </style>

View File

@ -13,7 +13,12 @@
<img :src="emptySvg" class="loading-state__img" alt="error" /> <img :src="emptySvg" class="loading-state__img" alt="error" />
<p class="loading-state__text loading-state__text--error">{{ errorText }}</p> <p class="loading-state__text loading-state__text--error">{{ errorText }}</p>
<slot name="error-action"> <slot name="error-action">
<button v-if="showRetry" class="loading-state__retry-btn" @click="$emit('retry')"> <button
v-if="showRetry"
class="loading-state__retry-btn lx-btn lx-btn--sm lx-btn--primary"
type="button"
@click="$emit('retry')"
>
重新加载 重新加载
</button> </button>
</slot> </slot>
@ -98,24 +103,6 @@ defineEmits<{
&__retry-btn { &__retry-btn {
margin-top: 4px; margin-top: 4px;
padding: 8px 24px;
border: none;
border-radius: 20px;
background: $theme-color;
color: #333;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s, transform 0.15s;
&:hover {
background: $hover-theme-fill;
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
} }
// //

View File

@ -79,6 +79,12 @@ const router = createRouter({
name: 'english-tools', name: 'english-tools',
component: () => import('@/views/EnglishTools/index.vue'), component: () => import('@/views/EnglishTools/index.vue'),
}, },
{
path: '/english-tools/word-book',
name: 'english-word-book',
component: () => import('@/views/EnglishTools/WordBook.vue'),
meta: { hideLayout: true },
},
{ {
path: '/insights', path: '/insights',
name: 'insights', name: 'insights',

View File

@ -38,10 +38,17 @@
<div class="timer-chip">用时: <span class="time">{{ currentQuestion.duration }}s</span></div> <div class="timer-chip">用时: <span class="time">{{ currentQuestion.duration }}s</span></div>
<div class="header-actions"> <div class="header-actions">
<template v-if="isDetailMode"> <template v-if="isDetailMode">
<button class="light-action-btn" type="button">错题反馈</button> <button class="lx-btn lx-btn--sm lx-btn--secondary" type="button">错题反馈</button>
<button class="light-action-btn" type="button">举一反三</button> <button class="lx-btn lx-btn--sm lx-btn--secondary" type="button">举一反三</button>
</template> </template>
<button v-else class="next-btn" type="button" @click="goNextQuestion">下一题</button> <button
v-else
class="lx-btn lx-btn--md lx-btn--short lx-btn--primary"
type="button"
@click="goNextQuestion"
>
下一题
</button>
</div> </div>
</div> </div>
@ -370,28 +377,6 @@ function handleBack() {
gap: 28px; gap: 28px;
} }
.next-btn {
border-radius: 90px;
border: none;
background: #2f80ed;
color: #fff;
padding: 12px 32px;
font-size: 24px;
cursor: pointer;
}
.light-action-btn {
min-width: 150px;
border: none;
border-radius: 90px;
background: rgba(51, 126, 255, 0.10);
color: #2d6fe0;
cursor: pointer;
font-size: 22px;
font-weight: 500;
padding: 12px 26px;
}
.page-content { .page-content {
flex: 1; flex: 1;
min-height: 0; min-height: 0;

View File

@ -0,0 +1,256 @@
<template>
<div class="word-book-page">
<SubPageHeader>
<template #center>
<div class="header-tabs">
<button
v-for="tab in pageTabs"
:key="tab.key"
class="header-tab"
:class="{ active: activeMode === tab.key }"
type="button"
@click="activeMode = tab.key"
>
{{ tab.label }}
</button>
</div>
</template>
</SubPageHeader>
<main class="word-book-body">
<section class="word-book-panel">
<WordBookFilterBar
v-model:grade="activeGrade"
v-model:status="activeStatus"
v-model:edition="activeEdition"
v-model:book="activeBook"
v-model:unit="activeUnit"
:mode="activeMode"
:count="filteredCount"
:grade-options="gradeOptions"
:status-options="statusOptions"
:edition-options="editionOptions"
:book-options="bookOptions"
:unit-options="unitOptions"
/>
<div class="word-book-content">
<LoadingState
v-if="displayWords.length === 0"
status="empty"
empty-text="暂无内容"
/>
<div v-else class="word-grid">
<WordCard
v-for="word in displayWords"
:key="word.id"
:word="word"
@click="openWordDetail"
/>
</div>
<button
v-if="displayWords.length > 0"
class="start-btn lx-btn lx-btn--primary lx-btn--short"
type="button"
>
开始练习
</button>
</div>
</section>
</main>
<WordDetailDialog v-model="showDetail" :word="currentWord" />
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import SubPageHeader from "@/components/layout/SubPageHeader.vue";
import LoadingState from "@/components/loading/LoadingState.vue";
import WordBookFilterBar from "./components/WordBookFilterBar.vue";
import WordCard, { type WordCardItem } from "./components/WordCard.vue";
import WordDetailDialog from "./components/WordDetailDialog.vue";
type PageMode = "stage" | "textbook";
const pageTabs = [
{ key: "stage" as const, label: "阶段" },
{ key: "textbook" as const, label: "教材" },
];
const gradeOptions = [
{ key: "all", label: "全部" },
{ key: "primary", label: "小学" },
{ key: "junior", label: "初中" },
{ key: "senior", label: "高中" },
{ key: "cet4", label: "四级" },
];
const statusOptions = [
{ key: "all", label: "全部" },
{ key: "unlocked", label: "未解锁" },
{ key: "new", label: "陌生" },
{ key: "known", label: "认识" },
{ key: "mastered", label: "掌握" },
];
const editionOptions = [
{ label: "译林版", value: "yilin" },
{ label: "人教版", value: "renjiao" },
{ label: "外研版", value: "waiyan" },
];
const bookOptions = [
{ label: "必修第一册", value: "book-1" },
{ label: "必修第二册", value: "book-2" },
{ label: "选择性必修", value: "book-3" },
];
const unitOptions = [
{ label: "Unit 1", value: "unit-1" },
{ label: "Unit 2", value: "unit-2" },
{ label: "Unit 3", value: "unit-3" },
];
const tones: WordCardItem["tone"][] = ["pink", "cream", "yellow", "green", "gray"];
const words: WordCardItem[] = Array.from({ length: 700 }, (_, index) => ({
id: index + 1,
word: "no",
meaning: "不;没有;不是",
partOfSpeech: "int.",
tone: tones[index % tones.length],
muted: index % tones.length === 4,
grade: index % 4 === 0 ? "primary" : index % 4 === 1 ? "junior" : index % 4 === 2 ? "senior" : "cet4",
status: statusOptions[(index % (statusOptions.length - 1)) + 1].key,
}));
const activeMode = ref<PageMode>("stage");
const activeGrade = ref("all");
const activeStatus = ref("all");
const activeEdition = ref("yilin");
const activeBook = ref("book-1");
const activeUnit = ref("unit-1");
const showDetail = ref(false);
const currentWord = ref<WordCardItem | null>(null);
const filteredWords = computed(() =>
words.filter((word) => {
if (activeGrade.value !== "all" && word.grade !== activeGrade.value) return false;
if (activeStatus.value !== "all" && word.status !== activeStatus.value) return false;
return true;
}),
);
const filteredCount = computed(() => {
if (activeGrade.value === "all" && activeStatus.value === "all") return words.length;
return filteredWords.value.length;
});
const displayWords = computed(() => filteredWords.value.slice(0, 80));
function openWordDetail(word: WordCardItem) {
currentWord.value = word;
showDetail.value = true;
}
</script>
<style scoped lang="scss">
.word-book-page {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
}
.header-tabs {
display: flex;
align-items: center;
gap: 70px;
}
.header-tab {
position: relative;
display: flex;
justify-content: center;
border: none;
background: transparent;
color: #121212;
cursor: pointer;
font-size: 36px;
font-weight: 700;
line-height: 1;
padding: 8px 0 16px;
&.active {
color: #2d6fe0;
&::after {
content: "";
position: absolute;
bottom: 0;
width: 32px;
height: 6px;
border-radius: 3px;
background: #3d78f2;
}
}
}
.word-book-body {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
padding: 0 64px 32px;
}
.word-book-panel {
display: flex;
width: 100%;
max-width: 1792px;
min-height: 0;
flex: 1;
flex-direction: column;
margin: 0 auto;
overflow: hidden;
border: 2px solid rgba(255, 255, 255, 0.72);
border-radius: 16px;
background: rgba(255, 255, 255, 0.3);
}
.word-book-content {
position: relative;
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
overflow-y: auto;
border-radius: 16px 16px 0 0;
background: #fff;
padding: 24px 32px 108px;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.word-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 28px;
}
.start-btn {
position: fixed;
bottom: 64px;
align-self: center;
margin-top: 20px;
padding: 12px 64px;
font-size: 28px;
height: 72px;
}
</style>

View File

@ -0,0 +1,96 @@
<template>
<div class="word-book-filter">
<div class="filter-left">
<LmFilterTabs v-model="gradeValue" :options="gradeOptions" mode="compact" />
<LmFilterTabs v-model="statusValue" :options="statusOptions" mode="compact" />
<template v-if="mode === 'textbook'">
<LmSelect v-model="editionValue" :options="editionOptions" width="150px" />
<LmSelect v-model="bookValue" :options="bookOptions" width="180px" />
<LmSelect v-model="unitValue" :options="unitOptions" width="130px" />
</template>
</div>
<div class="filter-count">数量:{{ count }}</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import LmFilterTabs, { type FilterTabOption } from "@/components/lm/LmFilterTabs.vue";
import LmSelect, { type LmSelectOption } from "@/components/lm/LmSelect.vue";
type Mode = "stage" | "textbook";
const props = defineProps<{
mode: Mode;
grade: string;
status: string;
edition: string | number;
book: string | number;
unit: string | number;
count: number;
gradeOptions: FilterTabOption[];
statusOptions: FilterTabOption[];
editionOptions: LmSelectOption[];
bookOptions: LmSelectOption[];
unitOptions: LmSelectOption[];
}>();
const emit = defineEmits<{
"update:grade": [value: string];
"update:status": [value: string];
"update:edition": [value: string | number];
"update:book": [value: string | number];
"update:unit": [value: string | number];
}>();
const gradeValue = computed({
get: () => props.grade,
set: (value: string) => emit("update:grade", value),
});
const statusValue = computed({
get: () => props.status,
set: (value: string) => emit("update:status", value),
});
const editionValue = computed({
get: () => props.edition,
set: (value: string | number) => emit("update:edition", value),
});
const bookValue = computed({
get: () => props.book,
set: (value: string | number) => emit("update:book", value),
});
const unitValue = computed({
get: () => props.unit,
set: (value: string | number) => emit("update:unit", value),
});
</script>
<style scoped lang="scss">
.word-book-filter {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
min-height: 92px;
padding: 16px 28px;
}
.filter-left {
display: flex;
align-items: center;
gap: 28px;
min-width: 0;
}
.filter-count {
color: #333;
font-size: 22px;
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,109 @@
<template>
<button
class="word-card"
:class="[`tone-${word.tone}`, { 'is-muted': word.muted }]"
type="button"
@click="$emit('click', word)"
>
<strong class="word-text">{{ word.word }}</strong>
<span class="word-meaning">{{ word.meaning }}</span>
<span class="word-pos">{{ word.partOfSpeech }}</span>
</button>
</template>
<script setup lang="ts">
export interface WordCardItem {
id: number;
word: string;
meaning: string;
partOfSpeech: string;
tone: "pink" | "cream" | "yellow" | "green" | "gray";
muted?: boolean;
grade: string;
status: string;
}
defineProps<{
word: WordCardItem;
}>();
defineEmits<{
click: [word: WordCardItem];
}>();
</script>
<style scoped lang="scss">
.word-card {
display: flex;
min-width: 0;
height: 248px;
flex-direction: column;
align-items: center;
justify-content: center;
border: 1px solid rgba(212, 222, 238, 0.8);
border-radius: 16px;
background: #fff;
box-shadow: 0 4px 12px rgba(54, 84, 132, 0.06);
cursor: pointer;
font: inherit;
transition:
transform 0.18s ease,
box-shadow 0.18s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(54, 84, 132, 0.12);
}
}
.tone-pink {
background: #fff5f6;
}
.tone-cream {
background: #fff9f4;
}
.tone-yellow {
background: #fffdf0;
}
.tone-green {
background: #effff7;
}
.tone-gray {
background: #f8f8f8;
}
.is-muted {
color: #999;
}
.word-text {
color: #121212;
font-size: 42px;
font-weight: 700;
line-height: 1;
}
.word-meaning {
margin-top: 32px;
color: #333;
font-size: 28px;
line-height: 1;
}
.word-pos {
margin-top: 20px;
color: #333;
font-size: 26px;
line-height: 1;
}
.is-muted .word-text,
.is-muted .word-meaning,
.is-muted .word-pos {
color: #999;
}
</style>

View File

@ -0,0 +1,305 @@
<template>
<el-dialog
:model-value="modelValue"
class="word-detail-dialog"
width="1000px"
:show-close="false"
:close-on-click-modal="true"
modal-class="word-detail-overlay"
align-center
@update:model-value="$emit('update:modelValue', $event)"
>
<div v-if="word" class="detail-panel">
<div class="detail-header">
<div class="word-main">
<h2 class="detail-word">{{ word.word }}</h2>
<button class="sound-row" type="button" aria-label="播放发音">
<svg class="sound-icon" viewBox="0 0 24 24" fill="none">
<path d="M4 9v6h4l5 4V5L8 9H4Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
<path d="M16 9c1.1 1.6 1.1 4.4 0 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
<span>/ˈklevə(r)/</span>
</button>
<p class="definition">int. 用于否定的回答或陈述没有不是</p>
</div>
<button class="book-link" type="button">
<svg viewBox="0 0 28 28" fill="none">
<path d="M7 4h11a3 3 0 0 1 3 3v17H9a2 2 0 0 1-2-2V4Z" stroke="currentColor" stroke-width="2" />
<path d="M7 20h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M13 9h5M13 14h5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<span>生词本</span>
</button>
</div>
<div class="tab-row">
<button
v-for="tab in tabs"
:key="tab.key"
class="detail-tab"
:class="{ active: activeTab === tab.key }"
type="button"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</div>
<div class="tab-content">
<div v-if="activeTab === 'example'" class="example-block">
<button class="sentence-audio" type="button" aria-label="播放例句">
<svg class="sound-icon" viewBox="0 0 24 24" fill="none">
<path d="M4 9v6h4l5 4V5L8 9H4Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
<path d="M16 9c1.1 1.6 1.1 4.4 0 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
<span>Just say yes or no.</span>
</button>
<p class="translation">只要说不是</p>
</div>
<div v-else-if="activeTab === 'mnemonic'" class="mnemonic-block">
<h3>形象联想</h3>
<p>
将字母 n 想象成一个摇头的动作o 想象成张大的嘴巴合起来就像在说时摇头和张嘴的样子
</p>
<h3>谐音记忆</h3>
<p>
no 的发音类似于中文的可以联想成不要再闹了来表示拒绝短小有力的发音也适合在回答问题时快速表达否定
</p>
<h3>用法提示</h3>
<p>作为否定回答时可单独使用也可以放在名词前表达没有</p>
</div>
<div v-else class="level-block">
<h3>1-第2关</h3>
<div class="record-grid">
<div class="record-card">
<span>选择题</span>
<p>正确 <strong class="success">5</strong> </p>
<p>错误 <strong class="danger">5</strong> </p>
</div>
<div class="record-card">
<span>拼写题</span>
<p>正确 <strong class="success">50</strong> </p>
<p>错误 <strong class="danger">999</strong> </p>
</div>
<div class="record-card">
<span>复习</span>
<p>成功 <strong class="success">555</strong> </p>
<p>失败 <strong class="danger">5</strong> </p>
</div>
</div>
</div>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import type { WordCardItem } from "./WordCard.vue";
const props = defineProps<{
modelValue: boolean;
word: WordCardItem | null;
}>();
defineEmits<{
"update:modelValue": [value: boolean];
}>();
const tabs = [
{ key: "example", label: "例句" },
{ key: "mnemonic", label: "助记" },
{ key: "level", label: "关卡" },
] as const;
type DetailTab = (typeof tabs)[number]["key"];
const activeTab = ref<DetailTab>("example");
watch(
() => props.modelValue,
(visible: boolean) => {
if (visible) activeTab.value = "example";
},
);
</script>
<style scoped lang="scss">
:global(.word-detail-dialog) {
--el-dialog-padding-primary: 0;
border-radius: 24px;
box-shadow: none;
}
:global(.word-detail-dialog .el-dialog__header) {
display: none;
}
:global(.word-detail-dialog .el-dialog__body) {
padding: 0;
}
:global(.word-detail-overlay) {
background-color: rgba(0, 0, 0, 0.58);
}
.detail-panel {
width: 1000px;
height: 680px;
padding: 64px 56px 58px;
overflow-y: auto;
border-radius: 24px;
background: #fff;
scrollbar-color: #d6dced transparent;
scrollbar-width: thin;
}
.detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 32px;
padding-bottom: 34px;
border-bottom: 1px solid #edf0f5;
}
.detail-word {
color: #121212;
font-size: 38px;
font-weight: 700;
line-height: 1;
}
.sound-row,
.sentence-audio,
.book-link {
display: inline-flex;
align-items: center;
border: none;
background: transparent;
color: #333;
cursor: pointer;
font: inherit;
}
.sound-row {
gap: 10px;
margin-top: 28px;
font-size: 24px;
}
.sound-icon {
width: 24px;
height: 24px;
}
.definition {
margin-top: 28px;
color: #333;
font-size: 24px;
}
.book-link {
gap: 8px;
color: #333;
font-size: 24px;
white-space: nowrap;
svg {
width: 28px;
height: 28px;
}
}
.tab-row {
display: flex;
align-items: center;
gap: 22px;
margin-top: 34px;
}
.detail-tab {
border: none;
border-radius: 999px;
background: transparent;
color: #333;
cursor: pointer;
font-size: 24px;
padding: 8px 18px;
&.active {
background: #eaf4ff;
color: #2d6fe0;
}
}
.tab-content {
margin-top: 32px;
color: #333;
font-size: 24px;
}
.sentence-audio {
gap: 10px;
font-size: 24px;
}
.translation {
margin-top: 12px;
padding-left: 34px;
color: #555;
font-size: 22px;
}
.mnemonic-block {
line-height: 1.7;
h3 {
margin: 0 0 16px;
color: #121212;
font-size: 24px;
font-weight: 700;
}
p + h3 {
margin-top: 28px;
}
}
.level-block h3 {
margin-bottom: 22px;
color: #121212;
font-size: 24px;
}
.record-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.record-card {
display: flex;
align-items: center;
gap: 30px;
min-height: 70px;
border: 1px solid #edf0f5;
border-radius: 10px;
padding: 0 22px;
color: #333;
font-size: 22px;
}
.success {
color: #12b886;
font-size: 28px;
}
.danger {
color: #e84040;
font-size: 28px;
}
</style>

View File

@ -20,6 +20,7 @@
v-for="tool in pageTools(pageIndex - 1)" v-for="tool in pageTools(pageIndex - 1)"
:key="tool + pageIndex" :key="tool + pageIndex"
class="tool-card" class="tool-card"
@click="handleToolClick(tool)"
> >
<h3 class="tool-title">{{ tool }}</h3> <h3 class="tool-title">{{ tool }}</h3>
</article> </article>
@ -44,6 +45,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { useRouter } from "vue-router";
import PageFrame from "@/components/layout/PageFrame.vue"; import PageFrame from "@/components/layout/PageFrame.vue";
const tools = [ const tools = [
@ -60,6 +62,7 @@ const tools = [
const pageSize = 9; const pageSize = 9;
const currentPage = ref(0); const currentPage = ref(0);
const router = useRouter();
const totalPages = computed(() => Math.ceil(tools.length / pageSize)); const totalPages = computed(() => Math.ceil(tools.length / pageSize));
const pageTools = (pageIndex: number) => const pageTools = (pageIndex: number) =>
@ -69,6 +72,11 @@ const trackStyle = computed(() => ({
transform: `translateX(-${currentPage.value * 100}%)`, transform: `translateX(-${currentPage.value * 100}%)`,
})); }));
function handleToolClick(tool: string) {
if (tool !== "单词书") return;
router.push({ name: "english-word-book" });
}
// ---- ---- // ---- ----
let touchStartX = 0; let touchStartX = 0;

View File

@ -11,7 +11,9 @@ function goHome() {
<template> <template>
<div class="login px-w"> <div class="login px-w">
<p>登录页占位</p> <p>登录页占位</p>
<el-button type="primary" @click="goHome">返回首页</el-button> <button class="lx-btn lx-btn--md lx-btn--primary" type="button" @click="goHome">
返回首页
</button>
</div> </div>
</template> </template>

View File

@ -48,7 +48,12 @@
</label> </label>
</div> </div>
<button class="edit-btn" :class="{ saveBtn: isEditing }" type="button" @click="toggleEditing"> <button
class="edit-btn lx-btn lx-btn--md"
:class="isEditing ? 'lx-btn--short saveBtn' : 'lx-btn--primary'"
type="button"
@click="toggleEditing"
>
<img v-if="!isEditing" class="edit-icon" :src="editIcon" alt="" /> <img v-if="!isEditing" class="edit-icon" :src="editIcon" alt="" />
{{ isEditing ? '保存' : '修改资料' }} {{ isEditing ? '保存' : '修改资料' }}
</button> </button>
@ -379,30 +384,21 @@ const toggleEditing = async () => {
} }
.edit-btn { .edit-btn {
min-width: 172px;
// height: 38px;
margin-top: auto; margin-top: auto;
border: none; gap: 5.5px;
border-radius: 999px; border-color: #fff;
background: #2d76ee;
// background: ;
color:#fff;
cursor: pointer;
font-size: 24px;
padding: 12px 24px;
border: 1px solid #fff;
} }
.saveBtn { .saveBtn {
background: #fff; background: #fff;
color: #2d76ee; color: #2d76ee;
border-color: #fff;
} }
.edit-icon { .edit-icon {
width: 21px; width: 21px;
height: 21px; height: 21px;
margin-right: 5.5px;
object-fit: contain; object-fit: contain;
vertical-align: -2px;
} }
.profile-main { .profile-main {

View File

@ -43,7 +43,13 @@
<img class="btn-icon" :src="cuotibenIcon" alt="错题本" /> <img class="btn-icon" :src="cuotibenIcon" alt="错题本" />
错题本 错题本
</button> </button>
<button class="add-book-btn" @click="showDrawer = true">+ 添加书本</button> <button
class="lx-btn lx-btn--md lx-btn--primary"
type="button"
@click="showDrawer = true"
>
+ 添加书本
</button>
</div> </div>
</div> </div>
@ -362,22 +368,6 @@ function goToSubjectAnalysis() {
height: 28px; height: 28px;
} }
.add-book-btn {
background: #3d78f2;
color: #fff;
border: none;
cursor: pointer;
font-size: 26px;
// font-weight: 600;
padding: 14px 32px;
border-radius: 99px;
transition: background 0.2s;
&:hover {
background: #2d64d8;
}
}
/* 卡片内容 */ /* 卡片内容 */
.card-content { .card-content {
display: flex; display: flex;

View File

@ -94,7 +94,13 @@
</div> </div>
<div class="record-footer"> <div class="record-footer">
<button class="start-btn" @click="handleStartPractice">开始练习</button> <button
class="lx-btn lx-btn--lg lx-btn--primary"
type="button"
@click="handleStartPractice"
>
开始练习
</button>
</div> </div>
</div> </div>
@ -497,23 +503,4 @@ function handleStartPractice() {
// border-top: 1.5px solid rgba(0, 0, 0, 0.04); // border-top: 1.5px solid rgba(0, 0, 0, 0.04);
} }
.start-btn {
padding: 18px 80px;
border-radius: 99px;
border: none;
background: #3d78f2;
color: #fff;
font-size: 28px;
cursor: pointer;
transition: background 0.2s, transform 0.15s;
&:hover {
background: #2d64d8;
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
</style> </style>

View File

@ -62,10 +62,18 @@
<!-- 操作按钮 --> <!-- 操作按钮 -->
<div class="action-row"> <div class="action-row">
<button class="action-btn detail-btn" @click="viewDetail(q)"> <button
class="action-btn lx-btn lx-btn--sm lx-btn--outline-primary"
type="button"
@click="viewDetail(q)"
>
查看详情 查看详情
</button> </button>
<button class="action-btn extend-btn" @click="extendPractice(q)"> <button
class="action-btn lx-btn lx-btn--sm lx-btn--outline-primary"
type="button"
@click="extendPractice(q)"
>
举一反三 举一反三
</button> </button>
</div> </div>
@ -112,10 +120,18 @@
</div> </div>
<div class="dialog-actions"> <div class="dialog-actions">
<button class="dialog-btn cancel-btn" type="button" @click="showRetrainDialog = false"> <button
class="dialog-btn lx-btn lx-btn--xl lx-btn--short lx-btn--default"
type="button"
@click="showRetrainDialog = false"
>
取消 取消
</button> </button>
<button class="dialog-btn confirm-btn" type="button" @click="confirmRetrain"> <button
class="dialog-btn lx-btn lx-btn--xl lx-btn--short lx-btn--primary"
type="button"
@click="confirmRetrain"
>
确认 确认
</button> </button>
</div> </div>
@ -628,28 +644,6 @@ function extendPractice(q: Question) {
.action-btn { .action-btn {
flex: 1; flex: 1;
font-size: 24px;
cursor: pointer;
padding: 12px 0;
border-radius: 99px;
text-align: center;
transition: opacity 0.2s;
&:hover {
opacity: 0.8;
}
}
.detail-btn {
background: #fff;
border: 1.5px solid #2d6fe0;
color: #2d6fe0;
}
.extend-btn {
background: #fff;
border: 1.5px solid #2d6fe0;
color: #2d6fe0;
} }
:global(.retrain-dialog) { :global(.retrain-dialog) {
@ -759,29 +753,4 @@ function extendPractice(q: Question) {
gap: 48px; gap: 48px;
} }
.dialog-btn {
width: 190px;
height: 72px;
border-radius: 40px;
cursor: pointer;
font-size: 26px;
font-weight: 600;
transition: opacity 0.2s;
&:hover {
opacity: 0.86;
}
}
.cancel-btn {
border: 2px solid #b7d4f2;
background: #fff;
color: #6ca8e8;
}
.confirm-btn {
border: none;
background: #2f80ed;
color: #fff;
}
</style> </style>

View File

@ -10,9 +10,21 @@
<div class="drawer-inner"> <div class="drawer-inner">
<!-- ===== 顶部操作栏 ===== --> <!-- ===== 顶部操作栏 ===== -->
<div class="drawer-header"> <div class="drawer-header">
<button class="cancel-btn" @click="handleCancel">取消</button> <button
class="lx-btn lx-btn--sm lx-btn--short lx-btn--default"
type="button"
@click="handleCancel"
>
取消
</button>
<span class="drawer-title">选择教材</span> <span class="drawer-title">选择教材</span>
<button class="confirm-btn" @click="handleConfirm">确认</button> <button
class="lx-btn lx-btn--sm lx-btn--short lx-btn--primary"
type="button"
@click="handleConfirm"
>
确认
</button>
</div> </div>
<!-- ===== 筛选区域 ===== --> <!-- ===== 筛选区域 ===== -->
@ -200,37 +212,6 @@ function handleCancel() {
color: #111; color: #111;
} }
.cancel-btn {
font-size: 26px;
color: #555;
background: transparent;
border: 2px solid #ccc;
border-radius: 99px;
padding: 12px 36px;
cursor: pointer;
transition: border-color 0.2s, color 0.2s;
&:hover {
border-color: #999;
color: #222;
}
}
.confirm-btn {
font-size: 26px;
color: #fff;
background: #3d78f2;
border: none;
border-radius: 99px;
padding: 12px 36px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #2d64d8;
}
}
/* ---- 筛选区域 ---- */ /* ---- 筛选区域 ---- */
.filter-section { .filter-section {
flex-shrink: 0; flex-shrink: 0;