Add floating translate panel and harden production frontend updates

Move translation to a left-side FAB with compact inline input, remove redundant nav and dashboard entry points, and improve deploy cache headers plus stale chunk recovery on login flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-05 10:00:45 +08:00
parent 59c562f707
commit a5846a7161
9 changed files with 416 additions and 19 deletions
+13 -2
View File
@@ -23,15 +23,26 @@ location /openapi.json {
proxy_set_header Host $host;
}
# 入口 HTML 禁止缓存(避免部署后仍引用已删除的旧 chunk)
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always;
add_header CDN-Cache-Control "no-store" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
}
# 静态资源不存在时必须 404,不能回退 index.html(否则 JS 加载失败页面空白)
location ^~ /assets/ {
try_files $uri =404;
expires 7d;
add_header Cache-Control "public, immutable";
add_header Cache-Control "public, max-age=604800, immutable" always;
}
# Vue Router history/login 等路径回退 index.html
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always;
add_header CDN-Cache-Control "no-store" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
}
+310
View File
@@ -0,0 +1,310 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { api, type TranslateResult } from '../api/request'
import { useTranslateFab } from '../composables/useTranslateFab'
const route = useRoute()
const { open, toggleFab, closeFab } = useTranslateFab()
const input = ref('')
const result = ref<TranslateResult | null>(null)
const message = ref('')
const messageType = ref<'success' | 'error'>('success')
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
message.value = ''
loading.value = false
adding.value = false
}
function handleToggle() {
toggleFab()
}
async function handleTranslate() {
const text = input.value.trim()
if (!text || loading.value) return
loading.value = true
message.value = ''
result.value = null
try {
const { data } = await api.translate(text)
result.value = data
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } }
message.value = err.response?.data?.detail || '翻译失败'
messageType.value = 'error'
} finally {
loading.value = false
}
}
async function addToLibrary() {
if (!result.value || adding.value) return
adding.value = true
message.value = ''
try {
await api.createWord({
source_text: result.value.source_text,
target_text: result.value.target_text,
source_lang: result.value.source_lang,
target_lang: result.value.target_lang,
phonetic: result.value.phonetic,
example_en: result.value.example_en,
example_cn: result.value.example_cn,
})
message.value = '已加入单词库 ✅'
messageType.value = 'success'
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } }
message.value = err.response?.data?.detail || '添加失败'
messageType.value = 'error'
} finally {
adding.value = false
}
}
function onInputKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleTranslate()
}
}
function close() {
closeFab()
}
watch(open, async (isOpen) => {
if (isOpen) {
await nextTick()
inputRef.value?.focus()
} else {
resetState()
}
})
watch(
() => route.path,
() => {
if (open.value) closeFab()
}
)
</script>
<template>
<div class="translate-fab-wrap">
<div v-if="open" class="translate-fab-backdrop" @click="close" />
<transition name="translate-panel">
<div v-if="open" class="translate-fab-panel" role="dialog" aria-label="快速翻译">
<div class="translate-input-row">
<input
ref="inputRef"
v-model="input"
type="text"
class="input translate-input"
placeholder="输入中文或英文,回车翻译"
@keydown="onInputKeydown"
/>
<button
type="button"
class="btn btn-primary translate-btn"
:disabled="loading || !input.trim()"
@click="handleTranslate"
>
{{ loading ? '…' : '翻译' }}
</button>
</div>
<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>
</div>
</div>
</transition>
<button
type="button"
class="translate-fab-btn"
:class="{ open }"
aria-label="快速翻译"
:aria-expanded="open"
@click="handleToggle"
>
<span class="fab-icon">{{ open ? '×' : '🔤' }}</span>
<span class="fab-label">翻译</span>
</button>
</div>
</template>
<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;
align-items: center;
gap: 10px;
}
.translate-fab-backdrop {
position: fixed;
inset: 0;
z-index: -1;
background: rgba(15, 23, 42, 0.25);
}
.translate-fab-panel {
width: min(calc(100vw - 96px), 320px);
padding: 12px;
background: #fff;
border-radius: 14px;
box-shadow: 0 8px 28px rgba(79, 110, 247, 0.2);
border: 1px solid var(--border);
}
.translate-input-row {
display: flex;
align-items: center;
gap: 8px;
}
.translate-input {
flex: 1;
min-width: 0;
padding: 9px 12px;
font-family: inherit;
font-size: 14px;
}
.translate-btn {
flex-shrink: 0;
width: auto;
padding: 8px 12px;
font-size: 13px;
line-height: 1.2;
white-space: nowrap;
}
.message {
margin-top: 8px;
margin-bottom: 0;
font-size: 13px;
padding: 8px 10px;
}
.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;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border: none;
border-radius: 50%;
background: #fff;
color: var(--primary);
border: 2px solid var(--primary);
box-shadow: 0 4px 16px rgba(79, 110, 247, 0.25);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s, background 0.15s;
}
.translate-fab-btn:hover {
transform: scale(1.04);
}
.translate-fab-btn.open {
background: var(--primary);
color: #fff;
}
.fab-icon {
font-size: 22px;
line-height: 1;
}
.fab-label {
font-size: 10px;
font-weight: 700;
margin-top: 2px;
}
.translate-panel-enter-active,
.translate-panel-leave-active {
transition: opacity 0.15s, transform 0.15s;
}
.translate-panel-enter-from,
.translate-panel-leave-to {
opacity: 0;
transform: translateX(-8px);
}
</style>
@@ -0,0 +1,19 @@
import { ref } from 'vue'
const open = ref(false)
export function useTranslateFab() {
function openFab() {
open.value = true
}
function closeFab() {
open.value = false
}
function toggleFab() {
open.value = !open.value
}
return { open, openFab, closeFab, toggleFab }
}
+20 -4
View File
@@ -1,16 +1,13 @@
<template>
<div class="main-layout">
<router-view />
<TranslateFab />
<TrainFab />
<nav class="nav-bottom">
<router-link to="/" class="nav-item">
<span class="nav-icon">📊</span>
首页
</router-link>
<router-link to="/translate" class="nav-item">
<span class="nav-icon">🔤</span>
翻译
</router-link>
<router-link to="/words" class="nav-item">
<span class="nav-icon">📚</span>
词库
@@ -28,5 +25,24 @@
</template>
<script setup lang="ts">
import { watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import TrainFab from '../components/TrainFab.vue'
import TranslateFab from '../components/TranslateFab.vue'
import { useTranslateFab } from '../composables/useTranslateFab'
const route = useRoute()
const router = useRouter()
const { openFab } = useTranslateFab()
watch(
() => route.path,
(path) => {
if (path === '/translate') {
openFab()
router.replace('/')
}
},
{ immediate: true }
)
</script>
+44 -1
View File
@@ -3,4 +3,47 @@ import App from './App.vue'
import router from './router'
import './styles.css'
createApp(App).use(router).mount('#app')
const CHUNK_RELOAD_KEY = 'wordloop-chunk-reload'
/** 部署后旧 bundle 引用已删除的 chunk 时,强制拉取最新 index.html */
function reloadOnStaleChunk(): void {
const attempts = Number(sessionStorage.getItem(CHUNK_RELOAD_KEY) || '0')
if (attempts >= 2) return
sessionStorage.setItem(CHUNK_RELOAD_KEY, String(attempts + 1))
const url = new URL(window.location.href)
url.searchParams.set('_cb', String(Date.now()))
window.location.replace(url.toString())
}
function isStaleChunkError(reason: unknown): boolean {
const msg = String(
reason instanceof Error ? reason.message : reason ?? '',
)
return (
msg.includes('Failed to fetch dynamically imported module') ||
msg.includes('Loading chunk') ||
msg.includes('ChunkLoadError')
)
}
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault()
reloadOnStaleChunk()
})
window.addEventListener('unhandledrejection', (event) => {
if (!isStaleChunkError(event.reason)) return
event.preventDefault()
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)
})
-1
View File
@@ -58,7 +58,6 @@ onMounted(async () => {
</div>
<div class="quick-actions">
<router-link to="/translate" class="btn btn-outline">去翻译</router-link>
<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>
+3 -4
View File
@@ -41,7 +41,7 @@ async function handleLogin() {
<div class="auth-logo">WordLoop</div>
<p class="auth-sub">单词循环记忆系统</p>
<div v-if="error" class="message error">{{ error }}</div>
<div class="card">
<form class="card" @submit.prevent="handleLogin">
<label class="label">用户名</label>
<input v-model="username" class="input" placeholder="请输入用户名" autocomplete="username" />
<label class="label">密码</label>
@@ -51,16 +51,15 @@ async function handleLogin() {
class="input"
placeholder="请输入密码"
autocomplete="current-password"
@keydown.enter="handleLogin"
/>
<label class="remember-row">
<input v-model="rememberMe" type="checkbox" class="remember-check" />
<span>记住登录状态关闭浏览器后仍保持登录有效期约 30 </span>
</label>
<button class="btn btn-primary" style="margin-top: 16px" :disabled="loading" @click="handleLogin">
<button type="submit" class="btn btn-primary" style="margin-top: 16px" :disabled="loading">
{{ loading ? '登录中...' : '登录' }}
</button>
</div>
</form>
<p class="auth-link">还没有账号<router-link to="/register">立即注册</router-link></p>
</div>
</template>
+6 -6
View File
@@ -44,17 +44,17 @@ async function handleRegister() {
<div class="page auth-page">
<div class="auth-logo">注册账号</div>
<div v-if="error" class="message error">{{ error }}</div>
<div class="card">
<form class="card" @submit.prevent="handleRegister">
<label class="label">用户名</label>
<input v-model="username" class="input" placeholder="2-50 个字符" />
<input v-model="username" class="input" placeholder="2-50 个字符" autocomplete="username" />
<label class="label">密码</label>
<input v-model="password" type="password" class="input" placeholder="至少 6 位" />
<input v-model="password" type="password" class="input" placeholder="至少 6 位" autocomplete="new-password" />
<label class="label">确认密码</label>
<input v-model="confirm" type="password" class="input" placeholder="再次输入密码" />
<button class="btn btn-primary" style="margin-top: 16px" :disabled="loading" @click="handleRegister">
<input v-model="confirm" type="password" class="input" placeholder="再次输入密码" autocomplete="new-password" />
<button type="submit" class="btn btn-primary" style="margin-top: 16px" :disabled="loading">
{{ loading ? '注册中...' : '注册' }}
</button>
</div>
</form>
<p class="auth-link">已有账号<router-link to="/login">去登录</router-link></p>
</div>
</template>
+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/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/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/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"}