perf(webui): 只打包 Vue 运行时、按需加载 zh-CN 语言包与布局弹窗,待审核光晕改为 opacity 动画

This commit is contained in:
Estrella Pan
2026-10-20 17:30:20 +02:00
parent 19e2e88171
commit 09f43ef78e
10 changed files with 126 additions and 26 deletions

View File

@@ -200,18 +200,40 @@ const posterSrc = computed(() => resolvePosterUrl(bangumi.poster_link));
display: block;
}
// Card glow animation when needs review - yellow
// 待审核的黄色光晕:静态 box-shadow 放在 ::after 上,只动画 opacity(合成器线程,
// 不触发每帧重绘)。光晕在海报外侧,所以这里取消 overflow 裁剪,由子元素继承圆角
.card-poster--needs-review {
animation: card-glow 2.5s ease-in-out infinite;
overflow: visible;
> * {
border-radius: inherit;
}
&::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 0 16px 4px rgba(251, 191, 36, 0.6);
pointer-events: none;
animation: card-glow 2.5s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
&::after {
animation: none;
opacity: 0.5;
}
}
}
@keyframes card-glow {
0%,
100% {
box-shadow: var(--shadow-md), 0 0 0 0 rgba(251, 191, 36, 0);
opacity: 0;
}
50% {
box-shadow: var(--shadow-md), 0 0 16px 4px rgba(251, 191, 36, 0.6);
opacity: 1;
}
}

View File

@@ -1,9 +1,13 @@
<script lang="ts" setup>
import AbSearchModal from './search/ab-search-modal.vue';
const { showModal, provider, loading } = storeToRefs(useSearchStore());
const { toggleModal, getProviders } = useSearchStore();
// 搜索弹窗首次打开时才加载
const { comp: SearchModal } = useLazyModal(
showModal,
() => import('./search/ab-search-modal.vue')
);
onMounted(() => {
getProviders();
});
@@ -18,5 +22,5 @@ onMounted(() => {
/>
<!-- Search Modal -->
<AbSearchModal @close="toggleModal" />
<component :is="SearchModal" v-if="SearchModal" @close="toggleModal" />
</template>

View File

@@ -18,6 +18,10 @@ const { isMobile } = useBreakpointQuery();
const showAccount = ref(false);
const rssRule = ref(ruleTemplate);
const { comp: AddRss, shown: addRssShown } = useLazyModal(
showAddRSS,
() => import('@/components/ab-add-rss.vue')
);
const { start, pause, shutdown, restart, resetRule } = useProgramStore();
const { refreshPoster } = useBangumiStore();
@@ -138,7 +142,13 @@ onUnmounted(() => {
</div>
<ab-change-account v-model:show="showAccount" />
<ab-add-rss v-model:show="showAddRSS" v-model:rule="rssRule" />
<component
:is="AddRss"
v-if="AddRss"
v-model:rule="rssRule"
:show="addRssShown"
@update:show="showAddRSS = $event"
/>
</div>
</template>

View File

@@ -615,12 +615,12 @@ function handleFilterClick(
<template>
<Teleport to="body">
<!-- Backdrop -->
<transition name="overlay">
<transition name="overlay" appear>
<div v-if="showModal" class="modal-backdrop" />
</transition>
<!-- Modal -->
<transition name="modal">
<transition name="modal" appear>
<div
v-if="showModal"
class="modal-container"

View File

@@ -0,0 +1,28 @@
import type { Component, Ref } from 'vue';
/**
* 弹窗首次打开时才加载并挂载。
* `comp` 就绪后才渲染,`shown` 在挂载后的下一拍再置为 true,
* 这样 NModal 的进入过渡(appear)仍然生效。
*/
export function useLazyModal(
open: Ref<boolean>,
load: () => Promise<{ default: Component }>
) {
const comp = shallowRef<Component>();
const shown = ref(false);
watch(
open,
async (val) => {
if (val && !comp.value) {
comp.value = (await load()).default;
await nextTick();
}
shown.value = open.value;
},
{ immediate: true }
);
return { comp, shown };
}

View File

@@ -1,15 +1,9 @@
import { createI18n } from 'vue-i18n';
import { createSharedComposable, useLocalStorage } from '@vueuse/core';
import enUS from '@/i18n/en.json';
import zhCN from '@/i18n/zh-CN.json';
import type { ApiSuccess } from '#/api';
const messages = {
en: enUS,
'zh-CN': zhCN,
};
type Languages = keyof typeof messages;
type Languages = 'en' | 'zh-CN';
function normalizeLocale(locale: string): Languages {
if (locale.startsWith('zh')) return 'zh-CN';
@@ -20,19 +14,39 @@ export const i18n = createI18n({
legacy: false,
locale: normalizeLocale(navigator.language),
fallbackLocale: 'en',
messages,
// zh-CN 先放空壳以固定语言类型,真正的消息由 loadLocale 载入
messages: { en: enUS, 'zh-CN': {} },
});
// 默认语言(en)静态打包;其它语言首次使用时才加载
const loaded = new Set<Languages>(['en']);
export async function loadLocale(locale: Languages) {
if (loaded.has(locale)) return;
const { default: msgs } = await import('@/i18n/zh-CN.json');
i18n.global.setLocaleMessage(locale, msgs);
loaded.add(locale);
}
/** 首次渲染前预载已保存的语言,避免先闪出英文 */
export function loadSavedLocale() {
return loadLocale(
normalizeLocale(localStorage.getItem('lang') ?? navigator.language)
);
}
export const useMyI18n = createSharedComposable(() => {
const lang = useLocalStorage<Languages>(
'lang',
normalizeLocale(navigator.language)
);
i18n.global.locale.value = lang.value as unknown as Languages;
i18n.global.locale.value = lang.value;
watch(lang, (val) => {
i18n.global.locale.value = val as unknown as Languages;
// 先加载语言包再切换,切换期间沿用旧语言
watch(lang, async (val) => {
await loadLocale(val);
i18n.global.locale.value = val;
});
function changeLocale() {

View File

@@ -1,7 +1,7 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import { router } from './router';
import { i18n } from './hooks/useMyI18n';
import { i18n, loadSavedLocale } from './hooks/useMyI18n';
import App from './App.vue';
import '@unocss/reset/tailwind-compat.css';
@@ -13,4 +13,5 @@ const app = createApp(App);
app.use(router);
app.use(pinia);
app.use(i18n);
app.mount('#app');
// 已保存的语言包加载完再渲染,避免先闪出英文
loadSavedLocale().then(() => app.mount('#app'));

View File

@@ -8,6 +8,14 @@ definePage({
});
const { editRule } = storeToRefs(useBangumiStore());
const editRuleOpen = computed({
get: () => editRule.value.show,
set: (val) => (editRule.value.show = val),
});
const { comp: EditRule, shown: editRuleShown } = useLazyModal(
editRuleOpen,
() => import('@/components/ab-edit-rule.vue')
);
// 插件页面的标题取自清单(清单到达前留空,避免闪出路由名 Plugin;清单已到达
// 或加载失败仍无匹配页面时回退为插件 id);插件市场用翻译后的标题,其它页面沿用路由名
@@ -74,9 +82,12 @@ function onApplyRule(rule: BangumiRule) {
</div>
</main>
<ab-edit-rule
v-model:show="editRule.show"
<component
:is="EditRule"
v-if="EditRule"
v-model:rule="editRule.item"
:show="editRuleShown"
@update:show="editRuleOpen = $event"
@enable="onEnableRule"
@archive="onArchiveRule"
@unarchive="onUnarchiveRule"

View File

@@ -67,6 +67,8 @@ declare global {
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const isValidNexusPhpCategoryIds: typeof import('../../src/utils/nexusphp').isValidNexusPhpCategoryIds
const loadLocale: typeof import('../../src/hooks/useMyI18n').loadLocale
const loadSavedLocale: typeof import('../../src/hooks/useMyI18n').loadSavedLocale
const localizeFields: typeof import('../../src/utils/plugin-text').localizeFields
const mapActions: typeof import('pinia').mapActions
const mapGetters: typeof import('pinia').mapGetters
@@ -150,6 +152,7 @@ declare global {
const useI18n: typeof import('vue-i18n').useI18n
const useId: typeof import('vue').useId
const useIntervalFn: typeof import('@vueuse/core').useIntervalFn
const useLazyModal: typeof import('../../src/hooks/useLazyModal').useLazyModal
const useLocalStorage: typeof import('@vueuse/core').useLocalStorage
const useLogStore: typeof import('../../src/store/log').useLogStore
const useMessage: typeof import('../../src/hooks/useMessage').useMessage

View File

@@ -46,6 +46,7 @@ export default defineConfig({
}),
VueI18nPlugin({
include: resolve(import.meta.dirname, './src/i18n/**'),
dropMessageCompiler: true,
}),
],
css: {
@@ -61,7 +62,13 @@ export default defineConfig({
output: { minify: { compress: { dropConsole: true } } },
},
},
resolve: { tsconfigPaths: true },
resolve: {
tsconfigPaths: true,
// 只用运行时:模板都在构建期编译,不需要打包 @vue/compiler-*
alias: [
{ find: /^vue$/, replacement: 'vue/dist/vue.runtime.esm-bundler.js' },
],
},
preview: { headers },
server: {
proxy: {