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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user