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..32532921 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,11 @@ "create": "tsx scripts/index.ts", "upload": "tsx scripts/upload.ts", "update": "tsx scripts/update.ts", + "update:ssr": "tsx scripts/update-ssr.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 +37,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 +54,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..8565f0b3 --- /dev/null +++ b/scripts/prerender.ts @@ -0,0 +1,58 @@ +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}`, + ); + // 注入到 开头、bundle 样式 之前:与运行时 css-render 挂载到 + // document.head(位于正文里 uno 样式之前)的层叠顺序一致,同优先级时 uno 工具类胜出 + 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/scripts/prune.ts b/scripts/prune.ts index aeaad05f..80ebe7ea 100644 --- a/scripts/prune.ts +++ b/scripts/prune.ts @@ -3,10 +3,16 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import OSS from "ali-oss"; +import { config as loadEnv } from "dotenv"; const BUILD_DIR = "dist"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +loadEnv({ + path: [path.join(__dirname, "../.env.dev"), path.join(__dirname, "../.env")], + quiet: true, +}); + const dirents = fs.readdirSync(path.join(__dirname, `../${BUILD_DIR}/`), { withFileTypes: true, }); diff --git a/scripts/update-ssr.ts b/scripts/update-ssr.ts new file mode 100644 index 00000000..d768d128 --- /dev/null +++ b/scripts/update-ssr.ts @@ -0,0 +1,55 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { config as loadEnv } from "dotenv"; + +import { edit, login } from "./api"; + +import type { shells as shellsType } from "../src/prerender"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const bundlePath = path.join(__dirname, "../dist-ssr/index.js"); + +loadEnv({ + path: [path.join(__dirname, "../.env.dev"), path.join(__dirname, "../.env")], + quiet: true, +}); + +const { PRTS_USERNAME, PRTS_PASSWORD } = process.env; + +if (!PRTS_USERNAME || !PRTS_PASSWORD) { + throw new Error( + "Missing required environment variables: PRTS_USERNAME, PRTS_PASSWORD", + ); +} + +if (!fs.existsSync(bundlePath)) { + throw new Error( + `SSR bundle not found at ${bundlePath}, run "pnpm run build" first`, + ); +} + +const { shells } = (await import(pathToFileURL(bundlePath).href)) as { + shells: typeof shellsType; +}; + +await login(PRTS_USERNAME, PRTS_PASSWORD); + +for (const name of Object.keys(shells)) { + const content = fs.readFileSync( + path.join(__dirname, "../dist/templates/", `${name}.html`), + { encoding: "utf8" }, + ); + await edit( + `Widget:${name}/dev/ssr`, + content + .replace("", "") + .replace("", "") + .replace("{{Documentation}}", () => `{{#widget:${name}/dev/ssr}}`), + ); + console.log( + `[update-ssr] https://prts.wiki/w/Widget:${name}/dev/ssr updated`, + ); +} diff --git a/scripts/upload.ts b/scripts/upload.ts index 2bcfc296..77338ae1 100644 --- a/scripts/upload.ts +++ b/scripts/upload.ts @@ -3,10 +3,16 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import OSS from "ali-oss"; +import { config as loadEnv } from "dotenv"; const BUILD_DIR = "dist"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +loadEnv({ + path: [path.join(__dirname, "../.env.dev"), path.join(__dirname, "../.env")], + quiet: true, +}); + const dirents = fs.readdirSync(path.join(__dirname, `../${BUILD_DIR}/`), { withFileTypes: true, }); 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/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..9518b238 100644 --- a/src/utils/theme.ts +++ b/src/utils/theme.ts @@ -1,13 +1,17 @@ import { computed, ref, type ComputedRef, type Ref } from "vue"; -import { useMutationObserver } from "@vueuse/core"; +import { isClient, useMutationObserver } from "@vueuse/core"; import { darkTheme, type GlobalTheme, 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 isWikiNight = () => { + if (!isClient) return false; + const { classList } = document.documentElement; if (classList.contains("skin-theme-clientpref-night")) return true; @@ -78,13 +82,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..1701c6aa 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,3 +1,4 @@ +import { isClient } from "@vueuse/core"; import MD5 from "md5"; import { MEDIA_ENDPOINT } from "./consts"; @@ -66,11 +67,13 @@ export function sum(arr: Array) { } export function isMobile(): boolean { + if (!isClient) 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 (!isClient) return false; return !!document .querySelectorAll("body")[0] .classList.contains("skin-minerva"); 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 @@ +
+ 加载失败,点击重试 +
+
+ + +
+ - - +
+
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 = (