From 51d2437b1c1dbbd50251b6751b952ea1179399af Mon Sep 17 00:00:00 2001 From: StarHeartHunt Date: Sun, 26 Jul 2026 17:31:08 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(cvlist):=20=E6=8C=82=E8=BD=BD=E5=90=8E?= =?UTF-8?q?=E5=85=88=E6=B8=B2=E6=9F=93=E5=8A=A0=E8=BD=BD=E9=AA=A8=E6=9E=B6?= =?UTF-8?q?=EF=BC=8C=E6=95=B0=E6=8D=AE=E6=8B=89=E5=8F=96=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=8F=AF=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entries/CVList.ts | 101 +------------------------------ src/widgets/CVList/data.ts | 113 +++++++++++++++++++++++++++++++++++ src/widgets/CVList/index.vue | 99 ++++++++++++++++++++---------- 3 files changed, 182 insertions(+), 131 deletions(-) create mode 100644 src/widgets/CVList/data.ts diff --git a/src/entries/CVList.ts b/src/entries/CVList.ts index 28129b1e..6c0b9977 100644 --- a/src/entries/CVList.ts +++ b/src/entries/CVList.ts @@ -1,104 +1,9 @@ import "virtual:uno.css"; import { createApp } from "vue"; -import { TORAPPU_ENDPOINT } from "@/utils/consts"; import CVList from "@/widgets/CVList/index.vue"; -import type { CharWordTable, SkinTable } from "@/widgets/CVList/types"; -async function initSkinTable() { - const response = await fetch( - new URL("/gamedata/latest/excel/skin_table.json", TORAPPU_ENDPOINT), - ); - const table: SkinTable = await response.json(); - const avatarMapping: Record = {}; - const charMapping: Record = {}; - - for (const skin of Object.values(table.charSkins)) { - if (skin.voiceId) { - avatarMapping[skin.voiceId] = skin.avatarId; - charMapping[skin.voiceId] = skin.charId; - } - } - - const buildinPatchMap = table.buildinPatchMap; - if (buildinPatchMap) { - for (const charPatch of Object.values(buildinPatchMap)) { - for (const [charPatchId, charSkinId] of Object.entries(charPatch)) { - avatarMapping[charPatchId] = table.charSkins[charSkinId].avatarId; - } - } - } - - return { charMapping, avatarMapping }; -} - -async function initCharMap() { - const response = await fetch( - `/api.php?${new URLSearchParams({ - action: "cargoquery", - format: "json", - tables: "chara", - limit: "5000", - fields: "charId, _pageName=pageName", - })}`, - ); - const json = await response.json(); - const cargoquery = json.cargoquery; - - const mapping: Record = {}; - for (const query of cargoquery) { - const { charId, pageName } = query.title; - if (charId && pageName) { - mapping[charId] = pageName; - } - } - - return { mapping }; -} - -async function initCharWord() { - const response = await fetch( - new URL("/gamedata/latest/excel/charword_table.json", TORAPPU_ENDPOINT), - ); - const table: CharWordTable = await response.json(); - const langTypes = table.voiceLangTypeDict; - const data: Record>> = Object.fromEntries( - Object.keys(langTypes).map((langType) => [langType, {}]), - ); - - for (const [charId, voiceLang] of Object.entries(table.voiceLangDict)) { - const { dict } = voiceLang; - for (const charVoice of Object.values(dict)) { - const cvName = charVoice.cvName; - for (const name of cvName) { - if (!data[charVoice.voiceLangType][name]) { - data[charVoice.voiceLangType][name] = new Set(); - } - data[charVoice.voiceLangType][name].add(charId); - } - } - } - - return { data, langTypes }; -} - -async function main() { - const retvals = await Promise.all([ - initCharWord(), - initCharMap(), - initSkinTable(), - ]); - - const props = {}; - for (const retval of retvals) { - Object.assign(props, retval); - } - - const ele = document.querySelector("#root"); - if (ele) { - const app = createApp(CVList, props); - app.mount(ele); - } +const ele = document.querySelector("#root"); +if (ele) { + createApp(CVList).mount(ele); } - -main(); diff --git a/src/widgets/CVList/data.ts b/src/widgets/CVList/data.ts new file mode 100644 index 00000000..4344cb6b --- /dev/null +++ b/src/widgets/CVList/data.ts @@ -0,0 +1,113 @@ +import { TORAPPU_ENDPOINT } from "@/utils/consts"; + +import type { CharWordTable, SkinTable, VoiceLangTypeData } from "./types"; + +export interface CVListData { + data: Record>; + langTypes: VoiceLangTypeData; + mapping: Record; + avatarMapping: Record; + charMapping: Record; +} + +async function initSkinTable() { + const response = await fetch( + new URL("/gamedata/latest/excel/skin_table.json", TORAPPU_ENDPOINT), + ); + const table: SkinTable = await response.json(); + const avatarMapping: Record = {}; + const charMapping: Record = {}; + + for (const skin of Object.values(table.charSkins)) { + if (skin.voiceId) { + avatarMapping[skin.voiceId] = skin.avatarId; + charMapping[skin.voiceId] = skin.charId; + } + } + + const buildinPatchMap = table.buildinPatchMap; + if (buildinPatchMap) { + for (const charPatch of Object.values(buildinPatchMap)) { + for (const [charPatchId, charSkinId] of Object.entries(charPatch)) { + avatarMapping[charPatchId] = table.charSkins[charSkinId].avatarId; + } + } + } + + return { charMapping, avatarMapping }; +} + +async function initCharMap() { + const response = await fetch( + `/api.php?${new URLSearchParams({ + action: "cargoquery", + format: "json", + tables: "chara", + limit: "5000", + fields: "charId, _pageName=pageName", + })}`, + ); + const json = await response.json(); + const cargoquery = json.cargoquery; + + const mapping: Record = {}; + for (const query of cargoquery) { + const { charId, pageName } = query.title; + if (charId && pageName) { + mapping[charId] = pageName; + } + } + + return { mapping }; +} + +async function initCharWord() { + const response = await fetch( + new URL("/gamedata/latest/excel/charword_table.json", TORAPPU_ENDPOINT), + ); + const table: CharWordTable = await response.json(); + const langTypes = table.voiceLangTypeDict; + const collected: Record< + string, + Record> + > = Object.fromEntries( + Object.keys(langTypes).map((langType) => [langType, {}]), + ); + + for (const [charId, voiceLang] of Object.entries(table.voiceLangDict)) { + const { dict } = voiceLang; + for (const charVoice of Object.values(dict)) { + const cvName = charVoice.cvName; + for (const name of cvName) { + if (!collected[charVoice.voiceLangType][name]) { + collected[charVoice.voiceLangType][name] = new Set(); + } + collected[charVoice.voiceLangType][name].add(charId); + } + } + } + + const data: Record> = Object.fromEntries( + Object.entries(collected).map(([langType, cvMap]) => [ + langType, + Object.fromEntries( + Object.entries(cvMap).map(([cvName, charIds]) => [ + cvName, + Array.from(charIds), + ]), + ), + ]), + ); + + return { data, langTypes }; +} + +export async function fetchCVListData(): Promise { + const [charWord, charMap, skinTable] = await Promise.all([ + initCharWord(), + initCharMap(), + initSkinTable(), + ]); + + return { ...charWord, ...charMap, ...skinTable }; +} diff --git a/src/widgets/CVList/index.vue b/src/widgets/CVList/index.vue index 7691e688..3b1fa533 100644 --- a/src/widgets/CVList/index.vue +++ b/src/widgets/CVList/index.vue @@ -1,45 +1,62 @@ +
+ 加载失败,点击重试 +
+
+ + +
+ - - +
+
From f49323c13bf8153973da2bf4475aef5117d80fe9 Mon Sep 17 00:00:00 2001 From: StarHeartHunt Date: Sun, 26 Jul 2026 17:31:18 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=E6=9E=84=E5=BB=BA=E6=9C=9F?= =?UTF-8?q?=E9=A2=84=E6=B8=B2=E6=9F=93=20widget=20=E9=9D=99=E6=80=81?= =?UTF-8?q?=E5=A4=96=E5=A3=B3=E6=B3=A8=E5=85=A5=E6=A8=A1=E6=9D=BF=EF=BC=8C?= =?UTF-8?q?=E7=BC=93=E8=A7=A3=E9=A6=96=E5=B1=8F=E5=B8=83=E5=B1=80=E5=A1=8C?= =?UTF-8?q?=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 SSR 预渲染管线:vite.config.prerender.ts 构建 Node 端 bundle, scripts/prerender.ts 将外壳 HTML 与 css-render 收集的样式注入 dist/templates/*.html,由 MediaWiki 服务端直出 - 接入 7 个 widget:CVList/EnemiesListV2/EquipList/HrCalculator/ MedalList/MemoryList/GachaSimulatorV2 - naive-ui widget 开启 inline-theme-disabled 压缩 SSR 体积 - useTheme/isMobileSkin/isMobile/getLanguage 增加无 DOM 环境回退 --- .gitignore | 371 +------------------------ package.json | 5 +- pnpm-lock.yaml | 14 +- scripts/prerender.ts | 56 ++++ src/prerender/index.ts | 76 +++++ src/utils/i18n.ts | 2 + src/utils/theme.ts | 22 +- src/utils/utils.ts | 2 + src/widgets/EnemiesListV2/index.vue | 1 + src/widgets/EquipList/index.vue | 1 + src/widgets/GachaSimulatorV2/index.vue | 11 +- src/widgets/HrCalculator/index.vue | 18 +- src/widgets/MedalList/MedalList.vue | 1 + src/widgets/MemoryList/index.vue | 1 + tsconfig.node.json | 2 +- vite.config.prerender.ts | 28 ++ 16 files changed, 227 insertions(+), 384 deletions(-) create mode 100644 scripts/prerender.ts create mode 100644 src/prerender/index.ts create mode 100644 vite.config.prerender.ts diff --git a/.gitignore b/.gitignore index c9f71bd2..0d361abe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,365 +1,12 @@ # Created by https://www.toptal.com/developers/gitignore/api/vue,linux,macos,windows,webstorm,visualstudiocode,node # Edit at https://www.toptal.com/developers/gitignore?templates=vue,linux,macos,windows,webstorm,visualstudiocode,node -### Linux ### -*~ - -# temporary files which can be created if a process still has a handle open of a deleted file -.fuse_hidden* - -# KDE directory preferences -.directory - -# Linux trash folder which might appear on any partition or disk -.Trash-* - -# .nfs files are created when an open file is removed but is still being accessed -.nfs* - -### macOS ### -# General -.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -### macOS Patch ### -# iCloud generated files -*.icloud - -### Node ### -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ +### Project +dist-ssr +.env.dev -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* - -### Node Patch ### -# Serverless Webpack directories -.webpack/ - -# Optional stylelint cache - -# SvelteKit build / generate output -.svelte-kit - -### VisualStudioCode ### -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -!.vscode/*.code-snippets - -# Local History for Visual Studio Code -.history/ - -# Built Visual Studio Code Extensions -*.vsix - -### VisualStudioCode Patch ### -# Ignore all local history of files -.history -.ionide - -### Vue ### -# gitignore template for Vue.js projects -# -# Recommended template: Node.gitignore - -# TODO: where does this rule come from? -docs/_book - -# TODO: where does this rule come from? -test/ - -### WebStorm ### -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# AWS User-specific -.idea/**/aws.xml - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -# auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr - -# CMake -cmake-build-*/ - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format -*.iws - -# IntelliJ -out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# SonarLint plugin -.idea/sonarlint/ - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -# Editor-based Rest Client -.idea/httpRequests - -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser - -### WebStorm Patch ### -# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 - -# *.iml -# modules.xml -# .idea/misc.xml -# *.ipr - -# Sonarlint plugin -# https://plugins.jetbrains.com/plugin/7973-sonarlint -.idea/**/sonarlint/ - -# SonarQube Plugin -# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin -.idea/**/sonarIssues.xml - -# Markdown Navigator plugin -# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced -.idea/**/markdown-navigator.xml -.idea/**/markdown-navigator-enh.xml -.idea/**/markdown-navigator/ - -# Cache file creation bug -# See https://youtrack.jetbrains.com/issue/JBR-2257 -.idea/$CACHE_FILE$ - -# CodeStream plugin -# https://plugins.jetbrains.com/plugin/12206-codestream -.idea/codestream.xml - -# Azure Toolkit for IntelliJ plugin -# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij -.idea/**/azureSettings.xml - -### Windows ### -# Windows thumbnail cache files -Thumbs.db -Thumbs.db:encryptable -ehthumbs.db -ehthumbs_vista.db - -# Dump file -*.stackdump - -# Folder config file -[Dd]esktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files -*.cab -*.msi -*.msix -*.msm -*.msp - -# Windows shortcuts -*.lnk - -# End of https://www.toptal.com/developers/gitignore/api/vue,linux,macos,windows,webstorm,visualstudiocode,node -# Created by https://www.toptal.com/developers/gitignore/api/vue,linux,macos,windows,webstorm,visualstudiocode,node -# Edit at https://www.toptal.com/developers/gitignore?templates=vue,linux,macos,windows,webstorm,visualstudiocode,node +# rollup-plugin-visualizer +stats.html ### Linux ### *~ @@ -718,11 +365,3 @@ $RECYCLE.BIN/ *.lnk # End of https://www.toptal.com/developers/gitignore/api/vue,linux,macos,windows,webstorm,visualstudiocode,node -public/results.json -public/adapters.json -public/bots.json -public/drivers.json -public/plugins.json - -# rollup-plugin-visualizer -stats.html diff --git a/package.json b/package.json index 22d72808..3a5dd1f8 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "update": "tsx scripts/update.ts", "prune": "tsx scripts/prune.ts", "dev": "vite", - "build": "vue-tsc -b && vite build", + "build": "vue-tsc -b && vite build && pnpm run prerender", + "prerender": "vite build -c vite.config.prerender.ts && tsx scripts/prerender.ts", "preview": "vite preview", "lint": "eslint .", "lint:fix": "eslint --fix .", @@ -35,6 +36,7 @@ "workbox-strategies": "^7.4.1" }, "devDependencies": { + "@css-render/vue3-ssr": "^0.15.14", "@eslint/js": "^10.0.1", "@types/ali-oss": "^6.23.3", "@types/crypto-js": "^4.2.2", @@ -51,6 +53,7 @@ "@vitejs/plugin-vue": "^6.0.8", "@vue/tsconfig": "^0.9.1", "ali-oss": "^6.23.0", + "dotenv": "^17.4.2", "eslint": "^10.8.0", "eslint-config-flat-gitignore": "^2.3.0", "eslint-config-prettier": "^10.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0ae398a..af746379 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: specifier: ^7.4.1 version: 7.4.1 devDependencies: + '@css-render/vue3-ssr': + specifier: ^0.15.14 + version: 0.15.14(vue@3.5.40(typescript@6.0.3)) '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) @@ -105,6 +108,9 @@ importers: ali-oss: specifier: ^6.23.0 version: 6.23.0(supports-color@8.1.1) + dotenv: + specifier: ^17.4.2 + version: 17.4.2 eslint: specifier: ^10.8.0 version: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) @@ -2049,6 +2055,10 @@ packages: resolution: {integrity: sha512-glXVh42vz40yZb9Cq2oMOt70FIoWiv+vxNvdKdU8CwjLad25qHM3trLxhl9bVjdr6WaslIXhWpn0NO8T/67Qjg==} engines: {node: '>= 8.0.0'} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -4446,7 +4456,7 @@ snapshots: detect-libc: 2.1.2 is-glob: 4.0.3 node-addon-api: 7.1.1 - picomatch: 4.0.4 + picomatch: 4.0.5 optionalDependencies: '@parcel/watcher-android-arm64': 2.5.6 '@parcel/watcher-darwin-arm64': 2.5.6 @@ -5369,6 +5379,8 @@ snapshots: digest-header@1.1.0: {} + dotenv@17.4.2: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 diff --git a/scripts/prerender.ts b/scripts/prerender.ts new file mode 100644 index 00000000..3c27d9ed --- /dev/null +++ b/scripts/prerender.ts @@ -0,0 +1,56 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import type { RenderedShell, shells as shellsType } from "../src/prerender"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const bundlePath = path.join(__dirname, "../dist-ssr/index.js"); +const templatesDir = path.join(__dirname, "../dist/templates"); + +if (!fs.existsSync(bundlePath)) { + throw new Error( + `SSR bundle not found at ${bundlePath}, run "vite build -c vite.config.prerender.ts" first`, + ); +} + +const { renderShell, shells } = (await import( + pathToFileURL(bundlePath).href +)) as { + renderShell: (name: string) => Promise; + shells: typeof shellsType; +}; + +for (const name of Object.keys(shells)) { + const templatePath = path.join(templatesDir, `${name}.html`); + if (!fs.existsSync(templatePath)) { + console.warn(`[prerender] template not found, skipped: ${templatePath}`); + continue; + } + + const rendered = await renderShell(name); + if (!rendered) continue; + const { html, styles, container } = rendered; + + const source = fs.readFileSync(templatePath, { encoding: "utf8" }); + const containerPattern = new RegExp(`(
]*>)(
)`); + if (!containerPattern.test(source)) { + console.warn( + `[prerender] container #${container} not found in ${name}.html, skipped`, + ); + continue; + } + + let result = source.replace( + containerPattern, + (_, open: string, close: string) => `${open}${html}${close}`, + ); + if (styles) result = result.replace("", () => `${styles}`); + + fs.writeFileSync(templatePath, result); + console.log( + `[prerender] ${name}: +${(html.length / 1024).toFixed(1)}KiB html, +${( + styles.length / 1024 + ).toFixed(1)}KiB styles`, + ); +} diff --git a/src/prerender/index.ts b/src/prerender/index.ts new file mode 100644 index 00000000..518abdf2 --- /dev/null +++ b/src/prerender/index.ts @@ -0,0 +1,76 @@ +import { createSSRApp, type Component } from "vue"; + +import { setup } from "@css-render/vue3-ssr"; +import { renderToString } from "vue/server-renderer"; + +import CVList from "@/widgets/CVList/index.vue"; +import EnemiesListV2 from "@/widgets/EnemiesListV2/index.vue"; +import EquipList from "@/widgets/EquipList/index.vue"; +import { + GachaRuleType, + type GachaPoolClientData as GachaClientPool, +} from "@/widgets/GachaSimulatorV2/gamedata-types"; +import GachaSimulatorV2 from "@/widgets/GachaSimulatorV2/index.vue"; +import type { GachaPoolClientData as GachaServerPool } from "@/widgets/GachaSimulatorV2/types"; +import HrCalculator from "@/widgets/HrCalculator/index.vue"; +import MedalList from "@/widgets/MedalList/MedalList.vue"; +import MemoryList from "@/widgets/MemoryList/index.vue"; + +interface ShellEntry { + component: Component; + props?: Record; + /** 构建产物模板里挂载容器的 id,默认 root */ + container?: string; +} + +// 让 GachaExecutor 以常驻池初始状态完成构造,仅用于外壳预渲染 +const emptyGachaServerPool = { + gachaPoolDetail: { + detailInfo: { + availCharInfo: { perAvailList: [] }, + upCharInfo: { perCharList: [] }, + weightUpCharInfoList: null, + }, + }, +} as unknown as GachaServerPool; + +const emptyGachaClientPool = { + gachaRuleType: GachaRuleType.NORMAL, + guarantee5Avail: 1, + guarantee5Count: 10, +} as unknown as GachaClientPool; + +export const shells: Record = { + CVList: { component: CVList }, + EnemiesListV2: { component: EnemiesListV2 }, + EquipList: { component: EquipList }, + HrCalculator: { component: HrCalculator }, + MedalList: { component: MedalList }, + MemoryList: { component: MemoryList }, + GachaSimulatorV2: { + component: GachaSimulatorV2, + props: { + gachaPoolId: "", + gachaBannerFile: "", + gachaClientPool: emptyGachaClientPool, + gachaServerPool: emptyGachaServerPool, + }, + }, +}; + +export interface RenderedShell { + html: string; + styles: string; + container: string; +} + +export async function renderShell(name: string): Promise { + const entry = shells[name]; + if (!entry) return null; + + const app = createSSRApp(entry.component, entry.props); + const { collect } = setup(app); + const html = await renderToString(app); + + return { html, styles: collect(), container: entry.container ?? "root" }; +} diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index f43f811a..13b4cf59 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -20,6 +20,8 @@ export enum LANGUAGES { } export function getLanguage() { + if (typeof navigator === "undefined" || !navigator.language) + return LANGUAGES.ZH; const language = navigator.language.toLowerCase(); const locales = [ LANGUAGES.EN, diff --git a/src/utils/theme.ts b/src/utils/theme.ts index e9af8b68..80314571 100644 --- a/src/utils/theme.ts +++ b/src/utils/theme.ts @@ -7,7 +7,13 @@ import { type GlobalThemeOverrides, } from "naive-ui"; +// Build-time prerendering (see src/prerender/) runs this module in Node, +// where there is no DOM; fall back to the light theme there. +const isClient = typeof document !== "undefined"; + const isWikiNight = () => { + if (!isClient) return false; + const { classList } = document.documentElement; if (classList.contains("skin-theme-clientpref-night")) return true; @@ -78,13 +84,15 @@ export const useTheme = ( } => { const isDark = ref(isWikiNight()); - useMutationObserver( - document.documentElement, - () => { - isDark.value = isWikiNight(); - }, - { attributeFilter: ["class"] }, - ); + if (isClient) { + useMutationObserver( + document.documentElement, + () => { + isDark.value = isWikiNight(); + }, + { attributeFilter: ["class"] }, + ); + } const theme = computed(() => (isDark.value ? darkTheme : null)); const themeOverrides = computed(() => diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 7af14c08..33829a7f 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -66,11 +66,13 @@ export function sum(arr: Array) { } export function isMobile(): boolean { + if (typeof window === "undefined") return false; return /(phone|pad|pod|iphone|ipod|ios|ipad|android|mobile|blackberry|iemobile|mqqbrowser|juc|fennec|wosbrowser|browserng|webos|symbian|windows phone)/i.test( window.navigator.userAgent, ); } export function isMobileSkin(): boolean { + if (typeof document === "undefined") return false; return !!document .querySelectorAll("body")[0] .classList.contains("skin-minerva"); diff --git a/src/widgets/EnemiesListV2/index.vue b/src/widgets/EnemiesListV2/index.vue index 9b28eb61..4744e49b 100644 --- a/src/widgets/EnemiesListV2/index.vue +++ b/src/widgets/EnemiesListV2/index.vue @@ -314,6 +314,7 @@ const handleUpdateFilter = (