├── server ├── .eslintignore ├── db │ └── nes.sqlite3 ├── .npmrc ├── location.json ├── .eslintrc.js ├── tsup.config.ts ├── src │ ├── services │ │ ├── categorys_service.ts │ │ ├── banner_service.ts │ │ └── roms_service.ts │ ├── sequelize │ │ ├── index.ts │ │ └── models │ │ │ ├── categorys_model.ts │ │ │ ├── banner_model.ts │ │ │ └── roms_model.ts │ ├── routers │ │ ├── banner_router.ts │ │ ├── categorys_router.ts │ │ └── rom_router.ts │ ├── utils │ │ ├── logger.ts │ │ ├── query.ts │ │ └── response.ts │ ├── server.d.ts │ ├── server.config.ts │ └── index.ts ├── .gitignore ├── test.html ├── pm2.json ├── tsconfig.json ├── LICENSE.md ├── package.json ├── publisher.json ├── README.md ├── source.json └── dist │ ├── index.js │ └── index.js.map ├── client ├── .eslintignore ├── src │ ├── App.vue │ ├── stores │ │ ├── dragged.ts │ │ ├── recent.ts │ │ ├── index.ts │ │ ├── current.ts │ │ ├── theme.ts │ │ └── controler.ts │ ├── utils │ │ ├── types.d.ts │ │ ├── notify.ts │ │ └── index.ts │ ├── pages │ │ ├── 404.vue │ │ ├── index.vue │ │ └── index │ │ │ ├── index.vue │ │ │ ├── GamePlayer.vue │ │ │ └── GameList.vue │ ├── composables │ │ ├── instance.ts │ │ └── mobile.ts │ ├── components │ │ ├── Icon │ │ │ ├── IconInner.vue │ │ │ ├── IconOutside.vue │ │ │ └── IconMob.vue │ │ ├── skeleton │ │ │ ├── BannerSkeletion.vue │ │ │ └── CardSkeletion.vue │ │ ├── HeadDrawer.vue │ │ ├── GameCard.vue │ │ ├── TestVue.vue │ │ ├── button │ │ │ ├── OBtn.vue │ │ │ └── NBtn.vue │ │ ├── RecomBox.vue │ │ ├── BannerCover.vue │ │ ├── MainFooter.vue │ │ ├── KeyboardOption.vue │ │ ├── GameCover.vue │ │ ├── SearchInput.vue │ │ ├── TBanner.vue │ │ ├── InnerLoading.vue │ │ ├── VolumeKnob.vue │ │ ├── AjaxBar.vue │ │ ├── KeySquare.vue │ │ ├── MainHeader.vue │ │ └── GameEmulator.vue │ ├── layouts │ │ └── MainLayout.vue │ ├── main.ts │ ├── client.config.ts │ ├── assets │ │ └── vue.svg │ ├── vite-env.d.ts │ ├── router │ │ ├── playgame.ts │ │ └── index.ts │ ├── nes.d.ts │ ├── axios │ │ └── index.ts │ ├── components.d.ts │ ├── css │ │ └── app.scss │ ├── auto-imports.d.ts │ └── options │ │ └── keyboard.ts ├── .vscode │ ├── settings.json │ └── extensions.json ├── public │ ├── logo.png │ ├── fonts │ │ └── zpix.woff2 │ └── icons │ │ ├── favicon.ico │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── mstile-150x150.png │ │ ├── apple-touch-icon.png │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── browserconfig.xml │ │ ├── site.webmanifest │ │ └── safari-pinned-tab.svg ├── .stylelintignore ├── tsconfig.node.json ├── .stylelintrc.js ├── uno.config.js ├── .eslintrc ├── .gitignore ├── postcss.config.js ├── index.html ├── LICENSE.md ├── README.md ├── tsconfig.json ├── package.json └── vite.config.ts ├── .gitignore ├── package.json ├── LICENSE.md └── README.md /server/.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist -------------------------------------------------------------------------------- /client/.eslintignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | /src-ssr -------------------------------------------------------------------------------- /client/src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /client/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "vue.codeActions.enabled": false 3 | } -------------------------------------------------------------------------------- /client/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-web/HEAD/client/public/logo.png -------------------------------------------------------------------------------- /server/db/nes.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-web/HEAD/server/db/nes.sqlite3 -------------------------------------------------------------------------------- /client/.stylelintignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /src-capacitor 3 | /src-cordova 4 | /.quasar 5 | /node_modules 6 | /src-ssr -------------------------------------------------------------------------------- /server/.npmrc: -------------------------------------------------------------------------------- 1 | ignore-workspace-root-check=true 2 | shamefully-hoist=true 3 | strict-peer-dependencies=false -------------------------------------------------------------------------------- /server/location.json: -------------------------------------------------------------------------------- 1 | {"0":true,"1":true,"3":true,"7":true,"9":true,"16":true,"17":true,"18":true,"-1":true} -------------------------------------------------------------------------------- /client/public/fonts/zpix.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-web/HEAD/client/public/fonts/zpix.woff2 -------------------------------------------------------------------------------- /client/public/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-web/HEAD/client/public/icons/favicon.ico -------------------------------------------------------------------------------- /server/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['@taiyuuki/eslint-config-ts'], 4 | } 5 | -------------------------------------------------------------------------------- /client/public/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-web/HEAD/client/public/icons/favicon-16x16.png -------------------------------------------------------------------------------- /client/public/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-web/HEAD/client/public/icons/favicon-32x32.png -------------------------------------------------------------------------------- /client/public/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-web/HEAD/client/public/icons/mstile-150x150.png -------------------------------------------------------------------------------- /client/public/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-web/HEAD/client/public/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /client/src/stores/dragged.ts: -------------------------------------------------------------------------------- 1 | export const useDragged = defineStore('dragged', { state: () => ({ target: document.createElement('div') }) }) 2 | -------------------------------------------------------------------------------- /client/public/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-web/HEAD/client/public/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /client/public/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taiyuuki/nes-web/HEAD/client/public/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /client/src/utils/types.d.ts: -------------------------------------------------------------------------------- 1 | import type { Controller } from "nes-vue" 2 | 3 | export type ControllerKeys = Exclude -------------------------------------------------------------------------------- /client/src/pages/404.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /client/src/stores/recent.ts: -------------------------------------------------------------------------------- 1 | export const useRecent = defineStore('recent', { 2 | state: () => ({ list: [] as RomInfo[] }), 3 | persist: true, 4 | }) 5 | -------------------------------------------------------------------------------- /client/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "vue.volar", 4 | "dbaeumer.vscode-eslint", 5 | "stylelint.vscode-stylelint", 6 | "antfu.unocss", 7 | ] 8 | } -------------------------------------------------------------------------------- /client/src/stores/index.ts: -------------------------------------------------------------------------------- 1 | import { createPinia } from 'pinia' 2 | import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' 3 | 4 | const pinia = createPinia() 5 | pinia.use(piniaPluginPersistedstate) 6 | 7 | export default pinia 8 | -------------------------------------------------------------------------------- /server/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | target: 'esnext', 6 | splitting: false, 7 | sourcemap: true, 8 | clean: true, 9 | }) 10 | -------------------------------------------------------------------------------- /client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /client/src/composables/instance.ts: -------------------------------------------------------------------------------- 1 | import type { Ref } from 'vue' 2 | 3 | export const useInstance = any>() => ref() as Ref> 4 | 5 | export const useELement = () => ref() as Ref 6 | -------------------------------------------------------------------------------- /client/src/composables/mobile.ts: -------------------------------------------------------------------------------- 1 | const isMobile = ref(false) 2 | const useMobile = () => { 3 | isMobile.value = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) 4 | return isMobile 5 | } 6 | 7 | export { useMobile } 8 | -------------------------------------------------------------------------------- /server/src/services/categorys_service.ts: -------------------------------------------------------------------------------- 1 | import { categorys_model } from '../sequelize/models/categorys_model' 2 | 3 | async function getAllCategorys() { 4 | const result = await categorys_model.findAll() 5 | return result 6 | } 7 | 8 | export { getAllCategorys } 9 | -------------------------------------------------------------------------------- /client/src/components/Icon/IconInner.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 14 | -------------------------------------------------------------------------------- /client/src/components/Icon/IconOutside.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 14 | -------------------------------------------------------------------------------- /client/public/icons/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /server/src/sequelize/index.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize } from 'sequelize' 2 | import { getDataBasePath } from '../server.config' 3 | 4 | const sequelize = new Sequelize({ 5 | dialect: 'sqlite', 6 | storage: getDataBasePath(), 7 | logging() { 8 | return 9 | }, 10 | }) 11 | 12 | export default sequelize 13 | -------------------------------------------------------------------------------- /client/src/components/skeleton/BannerSkeletion.vue: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .thumbs.db 3 | node_modules 4 | /**/node_modules 5 | 6 | # Log files 7 | /**/npm-debug.log* 8 | /**/yarn-debug.log* 9 | /**/yarn-error.log* 10 | /**/pnpm-debug.log* 11 | 12 | # Editor directories and files 13 | .idea 14 | *.suo 15 | *.ntvs* 16 | *.njsproj 17 | *.sln 18 | *.vscode 19 | 20 | fonts.txt -------------------------------------------------------------------------------- /client/.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'stylelint-config-standard', 4 | 'stylelint-config-standard-scss', 5 | 'stylelint-config-recommended-vue/scss', 6 | 'stylelint-config-rational-order', 7 | ], 8 | rules: { 9 | 'selector-class-pattern': '.*', 10 | 'max-line-length': [120, { ignorePattern: /content/ }], 11 | }, 12 | } -------------------------------------------------------------------------------- /client/src/stores/current.ts: -------------------------------------------------------------------------------- 1 | export const useCurrentGame = defineStore('current', { 2 | state: () => ({ 3 | game: {} as RomInfo, 4 | fromRouter: false, 5 | gain: 100, 6 | refresh: true, 7 | }), 8 | actions: { 9 | suspend() { 10 | this.gain = 0 11 | }, 12 | }, 13 | persist: true, 14 | }) 15 | -------------------------------------------------------------------------------- /client/uno.config.js: -------------------------------------------------------------------------------- 1 | import { 2 | defineConfig, 3 | presetUno, 4 | presetAttributify, 5 | presetIcons, 6 | } from 'unocss' 7 | import { presetTaiyuuki } from '@taiyuuki/unocss-preset' 8 | 9 | export default defineConfig({ 10 | presets: [ 11 | presetAttributify(), 12 | presetUno(), 13 | presetIcons(), 14 | presetTaiyuuki(), 15 | ], 16 | variants: [], 17 | }) 18 | -------------------------------------------------------------------------------- /server/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | !server/dist 13 | dist-ssr 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | !.vscode/settings.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | 28 | server/roms -------------------------------------------------------------------------------- /client/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "@taiyuuki/eslint-config-vue-unimport", 4 | "rules": { 5 | "import/no-unresolved": [ 6 | "error", 7 | { 8 | "ignore": [ 9 | "uno.css", 10 | "~pages", 11 | "virtual:generated-layouts", 12 | "virtual:generated-pages", 13 | "virtual:vue-component-preview" 14 | ] 15 | } 16 | ] 17 | } 18 | } -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | dev-dist 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | !.vscode/settings.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | 28 | fonts.txt 29 | -------------------------------------------------------------------------------- /client/src/layouts/MainLayout.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /client/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import route from './router' 4 | import pinia from './stores' 5 | import axios from './axios' 6 | import { createHead } from '@vueuse/head' 7 | import './css/app.scss' 8 | import 'element-plus/theme-chalk/dark/css-vars.css' 9 | import 'uno.css' 10 | 11 | const head = createHead() 12 | 13 | createApp(App).use(pinia).use(route).use(axios).use(head).mount('#app') 14 | -------------------------------------------------------------------------------- /client/src/client.config.ts: -------------------------------------------------------------------------------- 1 | export const config = { 2 | // 后端接口URL 3 | baseURL: 'http://localhost:8848', 4 | 5 | // 首页 6 | recentTotal: 8, // 最近游玩保存数量 7 | 8 | // 游戏列表页 9 | pageTotal: 20, // 每页显示数量 10 | cardSkeletonTotal: 12, // 骨架屏数量 11 | 12 | // 模拟器 13 | emulator: { 14 | saveTotal: 3, // 存档数量 15 | threshold: 0.2, // 虚拟摇杆的灵敏度 16 | degree: 45, // 虚拟摇杆斜向角度的范围 17 | }, 18 | recomTotal: 10, // 推荐游戏数量 19 | } 20 | -------------------------------------------------------------------------------- /client/src/assets/vue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/stores/theme.ts: -------------------------------------------------------------------------------- 1 | export const useTheme = defineStore('theme', { 2 | state: () => ({ color: '#1976d2' }), 3 | actions: { 4 | setColor(color: string) { 5 | this.color = color 6 | }, 7 | }, 8 | persist: true, 9 | }) 10 | 11 | export const useDark = defineStore('dark', { 12 | state: () => ({ value: false }), 13 | actions: { 14 | setDark() { 15 | this.value = !this.value 16 | }, 17 | }, 18 | persist: true, 19 | }) 20 | -------------------------------------------------------------------------------- /server/src/routers/banner_router.ts: -------------------------------------------------------------------------------- 1 | import { Router as rotuer } from 'express' 2 | import { getBanner } from '../services/banner_service' 3 | import { dispatchResponse } from '../utils/response' 4 | 5 | const banner = rotuer() 6 | 7 | banner.get('/banner', async (_, res) => { 8 | await dispatchResponse(async () => { 9 | const banner = await getBanner() 10 | res.send({ 11 | code: 200, 12 | banner, 13 | }) 14 | }, res) 15 | }) 16 | 17 | export default banner 18 | -------------------------------------------------------------------------------- /server/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { red, yellow, green } from 'kolorist' 2 | 3 | const info = function (str: string) { 4 | console.log(green(str)) 5 | } 6 | 7 | const warn = function (str: string) { 8 | console.log(yellow(str)) 9 | } 10 | 11 | const error = function (str: string) { 12 | console.log(red(str)) 13 | process.exit(0) 14 | } 15 | 16 | const nestLine = function () { 17 | console.log() 18 | } 19 | 20 | export { 21 | info, 22 | warn, 23 | error, 24 | nestLine, 25 | } 26 | -------------------------------------------------------------------------------- /client/public/icons/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/icons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/icons/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } -------------------------------------------------------------------------------- /client/src/components/Icon/IconMob.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nes-web", 3 | "version": "0.0.1", 4 | "description": "包含前后端的在线红白机游戏", 5 | "author": "taiyuuki ", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/taiyuuki/nes-web" 9 | }, 10 | "bugs": "https://github.com/taiyuuki/nes-web/issues", 11 | "homepage": "https://github.com/taiyuuki/nes-web#readme", 12 | "private": true, 13 | "dependencies": { 14 | "element-plus": "^2.3.14", 15 | "express": "^4.18.2", 16 | "nes-vue": "^1.7.2", 17 | "vue": "^3.3.4" 18 | } 19 | } -------------------------------------------------------------------------------- /server/src/routers/categorys_router.ts: -------------------------------------------------------------------------------- 1 | import { Router as router } from 'express' 2 | import { getAllCategorys } from '../services/categorys_service' 3 | import { dispatchResponse } from '../utils/response' 4 | 5 | const categorys = router() 6 | 7 | categorys.get('/categorys', async (_, res) => { 8 | await dispatchResponse(async () => { 9 | const reslult = await getAllCategorys() 10 | res.send({ 11 | code: 200, 12 | categorys: reslult, 13 | }) 14 | }, res) 15 | }) 16 | 17 | export default categorys 18 | -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('autoprefixer')({ 4 | overrideBrowserslist: [ 5 | 'last 4 Chrome versions', 6 | 'last 4 Firefox versions', 7 | 'last 4 Edge versions', 8 | 'last 4 Safari versions', 9 | 'last 4 Android versions', 10 | 'last 4 ChromeAndroid versions', 11 | 'last 4 FirefoxAndroid versions', 12 | 'last 4 iOS versions', 13 | ], 14 | }), 15 | ], 16 | } 17 | -------------------------------------------------------------------------------- /client/src/utils/notify.ts: -------------------------------------------------------------------------------- 1 | import { ElNotification as elNotify } from 'element-plus' 2 | import 'element-plus/theme-chalk/el-notification.css' 3 | 4 | export function errorNotify(info: string) { 5 | elNotify({ 6 | title: info, 7 | type: 'error', 8 | }) 9 | } 10 | 11 | export function infoNotify(info: string) { 12 | elNotify({ 13 | title: info, 14 | type: 'info', 15 | }) 16 | } 17 | 18 | export function successNotify(info: string) { 19 | elNotify({ 20 | title: info, 21 | type: 'success', 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /client/src/components/HeadDrawer.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 33 | -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import type { DefineComponent } from 'vue' 5 | const component: DefineComponent<{}, {}, any> 6 | export default component 7 | } 8 | declare module 'virtual:generated-layouts' { 9 | import type { RouteRecordRaw } from 'vue-router' 10 | export function setupLayouts(routes: RouteRecordRaw[]): RouteRecordRaw[] 11 | } 12 | declare module 'virtual:generated-pages' { 13 | import type { RouteRecordRaw } from 'vue-router' 14 | const routes: RouteRecordRaw[] 15 | export default routes 16 | } 17 | -------------------------------------------------------------------------------- /server/pm2.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "nes-web-server", 5 | "cwd": "dist", 6 | "script": "index.js", 7 | "instances": "1", 8 | "exec_mode": "cluster", 9 | "env": { 10 | "NODE_ENV": "development", 11 | "PORT": 8848 12 | }, 13 | "env_production": { 14 | "NODE_ENV": "production", 15 | "PORT": 80 16 | }, 17 | "log_date_format": "YYYY-MM-DD_HH:mm Z", 18 | "merge_logs": true 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /server/src/server.d.ts: -------------------------------------------------------------------------------- 1 | declare type Sort = 'desc' | 'asc' 2 | 3 | declare interface SelectSqlOption { 4 | select: string[] | string 5 | from: string 6 | where?: string[] 7 | order?: { by: string; sort: Sort } 8 | limit?: { page: number | string; count: number | string } 9 | slot?: string[] 10 | } 11 | 12 | declare interface RomInfo { 13 | id: string 14 | title: string 15 | cover: string 16 | image: string 17 | url: string 18 | language: string 19 | source: string 20 | comment: string 21 | size: string 22 | type: string 23 | category: string 24 | publisher: string 25 | location: string 26 | } -------------------------------------------------------------------------------- /client/src/components/skeleton/CardSkeletion.vue: -------------------------------------------------------------------------------- 1 | 28 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "esModuleInterop": true, 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "noEmit": true, 8 | "resolveJsonModule": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "target": "esnext", 13 | "isolatedModules": true, 14 | "useDefineForClassFields": true, 15 | "jsx": "preserve", 16 | "lib": [ 17 | "esnext", 18 | "dom" 19 | ], 20 | "baseUrl": ".", 21 | "paths": { 22 | "src/*": [ 23 | "src/*" 24 | ] 25 | } 26 | }, 27 | "exclude": [ 28 | "dist", 29 | "node_modules" 30 | ] 31 | } -------------------------------------------------------------------------------- /client/src/router/playgame.ts: -------------------------------------------------------------------------------- 1 | import router from 'src/router' 2 | import { isNotEmptyString } from 'src/utils' 3 | import pinia from 'src/stores' 4 | import { useCurrentGame } from 'src/stores/current' 5 | 6 | const current = useCurrentGame(pinia) 7 | 8 | export function pushToGamePlayer(id: string) { 9 | current.refresh && (current.refresh = false) 10 | router.push({ 11 | path: '/gameplayer', 12 | query: { id }, 13 | }) 14 | } 15 | 16 | export function searchGames(keyword: string) { 17 | if (isNotEmptyString(keyword)) { 18 | router.push({ 19 | path: '/gamelist', 20 | query: { keyword }, 21 | }) 22 | } 23 | else { 24 | router.push('/gamelist') 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /server/src/server.config.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path' 2 | import { getIpAddress } from './utils/response' 3 | 4 | const dbPath = '../db/nes.sqlite3' 5 | const romPath = '../roms' 6 | const romDir = '/roms/' 7 | const imgDir = '/roms/img/' 8 | const hostIp = getIpAddress() 9 | const getDataBasePath = () => join(__dirname, dbPath) 10 | const getRomPath = () => join(__dirname, romPath) 11 | 12 | const port = 8848 13 | let baseURL = `http://localhost:${port}` 14 | 15 | // 开发模式下配置主机为局域网ip,方便调试移动端 16 | if (process.env.NODE_ENV === 'development') { 17 | baseURL = `http://${hostIp}:${port}` 18 | } 19 | 20 | export { 21 | romDir, 22 | imgDir, 23 | getDataBasePath, 24 | getRomPath, 25 | port, 26 | baseURL, 27 | hostIp, 28 | } 29 | -------------------------------------------------------------------------------- /server/src/services/banner_service.ts: -------------------------------------------------------------------------------- 1 | import { roms_model } from '../sequelize/models/roms_model' 2 | import { banner_model } from '../sequelize/models/banner_model' 3 | import { resolveURL } from '../utils/query' 4 | import { imgDir } from '../server.config' 5 | 6 | async function getBanner() { 7 | const result = await banner_model.findAll({ 8 | attributes: ['id', 'title'], 9 | include: { 10 | model: roms_model, 11 | attributes: ['image'], 12 | }, 13 | }) 14 | return result.map(({ rom, title, id }) => { 15 | return { 16 | id, 17 | image: resolveURL(imgDir + rom.image), 18 | title: title, 19 | } 20 | }) 21 | } 22 | 23 | export { getBanner } 24 | -------------------------------------------------------------------------------- /server/src/sequelize/models/categorys_model.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, Model } from 'sequelize' 2 | import sequelize from '..' 3 | 4 | class Categorys extends Model { 5 | declare id: number 6 | declare name: string 7 | } 8 | 9 | const categorys_model = Categorys.init({ 10 | id: { 11 | type: DataTypes.TEXT, 12 | allowNull: false, 13 | primaryKey: true, 14 | }, 15 | name: { 16 | type: DataTypes.TEXT, 17 | allowNull: false, 18 | }, 19 | }, { 20 | sequelize, 21 | tableName: 'categorys', 22 | freezeTableName: true, 23 | createdAt: false, 24 | updatedAt: false, 25 | }) 26 | 27 | categorys_model.sync() 28 | 29 | type CategorysInstance = InstanceType 30 | 31 | export { categorys_model, type CategorysInstance } 32 | -------------------------------------------------------------------------------- /client/src/components/GameCard.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 37 | -------------------------------------------------------------------------------- /client/src/components/TestVue.vue: -------------------------------------------------------------------------------- 1 | 28 | -------------------------------------------------------------------------------- /client/src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createMemoryHistory, 3 | createRouter, 4 | createWebHashHistory, 5 | } from 'vue-router' 6 | // vite插件的虚拟模块,详情见:https://github.com/JohnCampionJr/vite-plugin-vue-layouts 7 | import { setupLayouts } from 'virtual:generated-layouts' 8 | import generatedRoutes from 'virtual:generated-pages' 9 | 10 | // 基于文件路径生成路由 11 | const routes = setupLayouts(generatedRoutes) 12 | 13 | // 添加404重定向 14 | routes.push({ 15 | path: '/:catchAll(.*)*', 16 | // component: () => import('pages/404.vue'), 17 | redirect: '/', 18 | }) 19 | 20 | // 创建路由模式 21 | const createHistory = import.meta.env.SSR ? createMemoryHistory : createWebHashHistory 22 | 23 | // 创建路由 24 | const route = createRouter({ 25 | scrollBehavior: () => ({ left: 0, top: 0 }), 26 | routes, 27 | history: createHistory(), 28 | }) 29 | export default route 30 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 在线红白机游戏 - 首页 14 | 15 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /client/src/components/button/OBtn.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 36 | 37 | 52 | -------------------------------------------------------------------------------- /server/src/sequelize/models/banner_model.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, Model } from 'sequelize' 2 | import sequelize from '..' 3 | import { roms_model } from './roms_model' 4 | 5 | class Banner extends Model { 6 | declare id: number 7 | declare title: string 8 | // roms表 9 | declare rom: { 10 | image: string 11 | } 12 | } 13 | 14 | const banner_model = Banner.init({ 15 | id: { 16 | type: DataTypes.INTEGER, 17 | allowNull: false, 18 | primaryKey: true, 19 | }, 20 | title: { 21 | type: DataTypes.TEXT, 22 | allowNull: false, 23 | }, 24 | }, { 25 | sequelize, 26 | tableName: 'banner', 27 | freezeTableName: true, 28 | createdAt: false, 29 | updatedAt: false, 30 | }) 31 | 32 | banner_model.belongsTo(roms_model, { foreignKey: 'id', targetKey: 'id' }) 33 | 34 | banner_model.sync() 35 | 36 | type BannerInstance = InstanceType 37 | 38 | export { banner_model, type BannerInstance } 39 | -------------------------------------------------------------------------------- /client/src/components/button/NBtn.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 37 | 38 | 53 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 taiyuuki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sub license, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /client/LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 taiyuuki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sub license, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /server/LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 taiyuuki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sub license, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /server/src/utils/query.ts: -------------------------------------------------------------------------------- 1 | import type { RomsInstance } from '../sequelize/models/roms_model' 2 | import { baseURL, romDir, imgDir } from '../server.config' 3 | 4 | function checkQuery(query: T): query is T & {} { 5 | if (typeof query === 'string') { 6 | return query.trim() !== '' 7 | } 8 | return query !== void 0 && query !== null 9 | } 10 | 11 | function resolveURL(str: string) { 12 | return baseURL + str 13 | } 14 | 15 | function resolveRomData(rom: R) { 16 | return { 17 | id: rom.id, 18 | category: rom.Category.dataValues.type, 19 | url: resolveURL(romDir + rom.url), 20 | cover: resolveURL(imgDir + rom.cover), 21 | image: resolveURL(imgDir + rom.image), 22 | title: rom.title, 23 | language: rom.language, 24 | type: rom.type, 25 | source: rom.source, 26 | comment: rom.comment, 27 | location: rom.location, 28 | size: rom.size, 29 | publisher: rom.publisher, 30 | } 31 | } 32 | 33 | export { checkQuery, resolveURL, resolveRomData } 34 | -------------------------------------------------------------------------------- /client/src/components/RecomBox.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 50 | -------------------------------------------------------------------------------- /client/src/nes.d.ts: -------------------------------------------------------------------------------- 1 | declare interface RomInfo { 2 | id: string 3 | title: string 4 | cover: string 5 | image: string 6 | url: string 7 | language: string 8 | source: string 9 | comment: string 10 | size: string 11 | type: string 12 | category: string 13 | publisher: string 14 | location: string 15 | } 16 | 17 | declare interface Category { 18 | id: string 19 | name: string 20 | } 21 | 22 | declare interface Suggestion { 23 | id: string 24 | cover: string 25 | value: string 26 | } 27 | 28 | declare type Player = 'p1' | 'p2' 29 | 30 | declare interface SaveData { 31 | id: string 32 | image: string 33 | title: string 34 | date: string 35 | } 36 | 37 | type UnionToCross = (T extends T ? (s: () => T) => void : never) extends ( 38 | s: infer R 39 | ) => void 40 | ? R 41 | : never 42 | 43 | type GetCrossLast = T extends () => infer R ? R : never 44 | 45 | declare type UnionToTuple = []> = [T] extends [never] 46 | ? Result 47 | : [ 48 | ...UnionToTuple>>>, 49 | GetCrossLast>, 50 | ] 51 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nes-web-server", 3 | "version": "0.0.1", 4 | "description": "nes game server side", 5 | "main": "./dist/index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/taiyuuki/nes-web" 9 | }, 10 | "bugs": "https://github.com/taiyuuki/nes-web/issues", 11 | "homepage": "https://github.com/taiyuuki/nes-web#readme", 12 | "scripts": { 13 | "lint": "eslint --ext .js,.ts ./ --fix", 14 | "dev": "tsup --watch", 15 | "build": "tsup", 16 | "server:dev": "pm2 start pm2.json --watch", 17 | "server:prod": "pm2 start pm2.json -env production", 18 | "test": "vitest" 19 | }, 20 | "keywords": [], 21 | "author": "taiyuuki ", 22 | "license": "MIT", 23 | "files": [ 24 | "dist" 25 | ], 26 | "devDependencies": { 27 | "@taiyuuki/eslint-config-ts": "^0.0.8", 28 | "@types/express": "^4.17.17", 29 | "@types/node": "^18.17.18", 30 | "eslint": "^8.50.0", 31 | "kolorist": "^1.8.0", 32 | "tsup": "^6.7.0", 33 | "typescript": "^4.9.5" 34 | }, 35 | "dependencies": { 36 | "express": "^4.18.2", 37 | "sequelize": "^6.33.0", 38 | "sqlite3": "^5.1.6" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client/src/components/BannerCover.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 34 | 35 | 60 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 |

在线红白机游戏

2 | 3 |

4 | vue versionnes-vue version 5 | element-plus version 6 |

7 | 8 |

9 | 基于vue3 + ts的在线FC(NES)🎮游戏项目,前端。 10 |

11 | 12 | * 框架:`vue3` 13 | * 构建工具:`vite` 14 | * FC模拟器组件:[taiyuuki/nes-vue](https://github.com/taiyuuki/nes-vue) 15 | * 组件库:`element-plus` 16 | * 类型检测:`typescript` 17 | * 前后端交互:`axios` 18 | * CSS预编译:`scss` 19 | * 代码格式:`eslint` `stylelint` 20 | * `vue3`生态 21 | * `vue-router` 22 | * `pinia` 23 | * `pinia-plugin-persistedstate`:pinia持久化插件 24 | * `vite`插件 25 | * `unocss`:CSS原子类生产 26 | * `unplugin-auto-import`:自动导入API 27 | * `unplugin-vue-components`:自动导入组件 28 | * `vite-plugin-pages`:基于文件自动创建路由 29 | * `vite-plugin-vue-layouts`:自动创建根路由 30 | * `vite-plugin-pwa`:PWA模式 31 | 32 | ## 项目运行 33 | 34 | 安装依赖 35 | 36 | ```shell 37 | yarn install 38 | ``` 39 | 40 | 运行 41 | 42 | ```shell 43 | yarn dev 44 | ``` 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response, RequestHandler, NextFunction } from 'express' 2 | import express from 'express' 3 | import roms from './routers/rom_router' 4 | import categorys from './routers/categorys_router' 5 | import { port, getRomPath, hostIp, baseURL } from './server.config' 6 | import * as logger from './utils/logger' 7 | import banner from './routers/banner_router' 8 | 9 | const setHeaders: RequestHandler = function ( 10 | req: Request, 11 | res: Response, 12 | next: NextFunction 13 | ) { 14 | res.setHeader('Access-Control-Allow-Origin', '*') // 允许跨域 15 | res.setHeader('Access-Control-Allow-Headers', '*') // 允许客户端设置请求头 16 | res.setHeader('Access-Control-Allow-Methods', '*') // 允许客户端的请求方式 17 | if (req.method === 'OPTIONS') {return res.sendStatus(200)} // options请求快速结束 18 | next() 19 | } 20 | const app = express() 21 | 22 | app.use(express.json()) 23 | // 请求头 24 | .use(setHeaders) 25 | // 静态资源 26 | .use('/roms', express.static(getRomPath())) 27 | // 路由 28 | .use(categorys) 29 | .use(roms) 30 | .use(banner) 31 | 32 | // 开发模式下配置本地ip域名 33 | if (process.env.NODE_ENV === 'development') { 34 | app.set('host', hostIp) 35 | } 36 | 37 | app.listen(port, () => { 38 | logger.info(`server: ${baseURL}`) 39 | }) 40 | -------------------------------------------------------------------------------- /server/src/utils/response.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from 'express' 2 | import * as logger from './logger' 3 | import os from 'os' 4 | 5 | function sendEmpty(res: Response, target: string) { 6 | res.send({ 7 | code: 400, 8 | message: `${target}内容不能为空`, 9 | }) 10 | } 11 | 12 | async function dispatchResponse( 13 | target: Function, 14 | res: Response, 15 | message?: string, 16 | err?: (args: any) => any 17 | ) { 18 | message = message ?? '发生错误' 19 | try { 20 | await target() 21 | } 22 | catch (e) { 23 | logger.error(`${e}`) 24 | if (err) { 25 | err(e) 26 | } 27 | res.send({ 28 | code: 500, 29 | msg: message, 30 | }) 31 | } 32 | } 33 | 34 | function getIpAddress() { 35 | const ifaces = os.networkInterfaces() 36 | for (const dev in ifaces) { 37 | const iface = ifaces[dev]! 38 | 39 | for (let i = 0; i < iface.length; i++) { 40 | const { family, address, internal } = iface[i] 41 | 42 | if (family === 'IPv4' && address !== '127.0.0.1' && !internal) { 43 | return address 44 | } 45 | } 46 | } 47 | return '127.0.0.1' 48 | } 49 | 50 | export { sendEmpty, dispatchResponse, getIpAddress } 51 | -------------------------------------------------------------------------------- /client/src/components/MainFooter.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 58 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "resolveJsonModule": true, 10 | "isolatedModules": true, 11 | "esModuleInterop": true, 12 | "lib": [ 13 | "ESNext", 14 | "DOM" 15 | ], 16 | "skipLibCheck": true, 17 | "noEmit": true, 18 | "baseUrl": "./", 19 | "paths": { 20 | "src/*": [ 21 | "src/*" 22 | ], 23 | "css/*": [ 24 | "*" 25 | ], 26 | "components/*": [ 27 | "src/components/*" 28 | ], 29 | "layouts/*": [ 30 | "src/layouts/*" 31 | ], 32 | "pages/*": [ 33 | "src/pages/*" 34 | ], 35 | "assets/*": [ 36 | "src/assets/*" 37 | ], 38 | "stores/*": [ 39 | "src/stores/*" 40 | ], 41 | "router/*": [ 42 | "src/router/*" 43 | ] 44 | } 45 | }, 46 | "include": [ 47 | "src/**/*.ts", 48 | "src/**/*.d.ts", 49 | "src/**/*.tsx", 50 | "src/**/*.vue" 51 | ], 52 | "references": [ 53 | { 54 | "path": "./tsconfig.node.json" 55 | } 56 | ], 57 | "types": [ 58 | "vite-plugin-pages/client", 59 | "vite-plugin-vue-layouts/client", 60 | "node" 61 | ] 62 | } -------------------------------------------------------------------------------- /client/src/pages/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 11 | 12 | 87 | -------------------------------------------------------------------------------- /server/src/sequelize/models/roms_model.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, Model } from 'sequelize' 2 | import sequelize from '..' 3 | import { categorys_model } from './categorys_model' 4 | 5 | function textField() { 6 | return { 7 | type: DataTypes.TEXT, 8 | allowNull: false, 9 | } 10 | } 11 | 12 | class Roms extends Model { 13 | declare id: number 14 | declare title: string 15 | declare cover: string 16 | declare image: string 17 | declare language: string 18 | declare type: string 19 | declare source: string 20 | declare comment: string 21 | declare location: string 22 | declare size: string 23 | declare publisher: string 24 | declare url: string 25 | // categorys表 26 | declare Category: { 27 | dataValues: { 28 | type: string 29 | } 30 | } 31 | } 32 | 33 | const roms_model = Roms.init({ 34 | id: { 35 | type: DataTypes.INTEGER, 36 | allowNull: false, 37 | primaryKey: true, 38 | }, 39 | title: textField(), 40 | cover: textField(), 41 | image: textField(), 42 | language: textField(), 43 | type: textField(), 44 | source: textField(), 45 | comment: textField(), 46 | location: textField(), 47 | size: textField(), 48 | publisher: textField(), 49 | url: textField(), 50 | }, 51 | { 52 | sequelize, 53 | modelName: 'roms', 54 | freezeTableName: true, 55 | createdAt: false, 56 | updatedAt: false, 57 | }) 58 | 59 | roms_model.belongsTo(categorys_model, { foreignKey: 'type', targetKey: 'id' }) 60 | roms_model.sync() 61 | 62 | type RomsInstance = InstanceType 63 | 64 | export { roms_model, type RomsInstance } 65 | -------------------------------------------------------------------------------- /server/publisher.json: -------------------------------------------------------------------------------- 1 | {"Namco":true,"Sachen":true,"外星科技":true,"Konami":true,"Idea-Tek":true,"Natsume":true,"Taito":true,"卡圣":true,"SunSoft":true,"鸿达":true,"Hudson":true,"Yutaka":true,"Bothtec":true,"Rex":true,"Technos":true,"Enix":true,"Nintendo":true,"SNK":true,"全崴资讯":true,"Square":true,"Super Tone":true,"火星电子":true,"未知":true,"Data East":true,"小天才":true,"Joy Van":true,"Tomy":true,"Tecmo":true,"Capcom":true,"Bandai":true,"Future":true,"Banpresto":true,"Cony Soft":true,"Naxat":true,"Tengen":true,"Asder":true,"Atari":true,"两亦":true,"南晶科技":true,"King Records":true,"Athena":true,"Human":true,"Irem":true,"ASCII":true,"Toei":true,"Tonkin House":true,"Jaleco":true,"Atlus":true,"Nihon Bussan":true,"Naltron":true,"Activision":true,"Toaplan":true,"Sammy":true,"Hect":true,"黄信维":true,"Kemco":true,"小霸王":true,"吉昌电子":true,"Epic":true,"UPL":true,"数奇玉":true,"Romstar":true,"晶碁电子":true,"Sofel":true,"JY Company":true,"Gouder":true,"三协资讯":true,"天苑软件":true,"柏青哥":true,"凌捷":true,"丰利":true,"荣丰":true,"Bit":true,"东生":true,"菜菜学堂":true,"Panesian":true,"Hot-B":true,"ABM":true,"北同方":true,"Character Soft":true,"Kawada":true,"Seta":true,"VIC Tokai":true,"恒格电子":true,"Koei":true,"IGS":true,"高昇达":true,"Altron":true,"Power Joy":true,"三佳":true,"Epoch":true,"晶科泰":true,"Towa Chiki":true,"Masaya":true,"奔力":true,"爱尔普":true,"Victor":true,"小视霸":true,"东达":true,"科王":true,"泽诚":true,"科达":true,"新科":true,"M&M":true,"TCL":true,"先达电子":true,"earth幻灭":true,"八德":true,"BiTe":true,"Hacker International":true,"Culture Brain":true,"Pack-In-Video":true,"LJN":true,"大华电脑":true,"Varie":true,"Somari":true,"Vic Tokai":true,"Taxan":true,"Tokuma Shoten":true,"dB-SOFT":true,"Kyugo Boueki":true,"CBS Sony Group":true,"Pony Canyon":true,"KAC":true,"Asmik":true,"HAL Laboratory":true,"Ultra Games":true} -------------------------------------------------------------------------------- /client/src/stores/controler.ts: -------------------------------------------------------------------------------- 1 | import type { Controller } from 'nes-vue' 2 | import { getKeys } from 'src/utils' 3 | 4 | export const useControler = defineStore('controler', { 5 | state: () => ({ 6 | p1: { 7 | UP: 'KeyW', 8 | DOWN: 'KeyS', 9 | LEFT: 'KeyA', 10 | RIGHT: 'KeyD', 11 | A: 'KeyK', 12 | B: 'KeyJ', 13 | C: 'KeyI', 14 | D: 'KeyU', 15 | SELECT: 'ShiftRight', 16 | START: 'Enter', 17 | } as Controller, 18 | p2: { 19 | UP: 'ArrowUp', 20 | DOWN: 'ArrowDown', 21 | LEFT: 'ArrowLeft', 22 | RIGHT: 'ArrowRight', 23 | A: 'Numpad2', 24 | B: 'Numpad1', 25 | C: 'Numpad5', 26 | D: 'Numpad4', 27 | } as Controller, 28 | p0: { 29 | SAVE: 'Digit1', 30 | LOAD: 'Digit2', 31 | PAUSE: 'KeyP', 32 | RESET: 'KeyR', 33 | SUSPEND: 'KeyV', 34 | CUT: 'Equal', 35 | FULL: 'KeyF', 36 | }, 37 | }), 38 | getters: { 39 | maps: (state) => { 40 | const keyMaps = {} as Record 41 | const p1_keys = getKeys(state.p1) 42 | const p2_keys = getKeys(state.p1) 43 | const p0_keys = getKeys(state.p0) 44 | p1_keys.forEach(key => { 45 | keyMaps[state.p1[key]] = 'p1' + key 46 | }) 47 | p2_keys.forEach(key => { 48 | keyMaps[state.p2[key]] = 'p2' + key 49 | }) 50 | p0_keys.forEach(key => { 51 | keyMaps[state.p0[key]] = 'p0' + key 52 | }) 53 | return keyMaps 54 | }, 55 | }, 56 | persist: true, 57 | }) 58 | -------------------------------------------------------------------------------- /client/src/components/KeyboardOption.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 79 | -------------------------------------------------------------------------------- /client/src/components/GameCover.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 96 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nes-web-client", 3 | "version": "0.1.0", 4 | "description": "Nes web client side", 5 | "author": "taiyuuki ", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/taiyuuki/nes-web" 9 | }, 10 | "bugs": "https://github.com/taiyuuki/nes-web/issues", 11 | "homepage": "https://github.com/taiyuuki/nes-web#readme", 12 | "private": true, 13 | "scripts": { 14 | "lint": "eslint --ext .js,.ts,.vue ./ --fix", 15 | "dev": "vite --host", 16 | "build": "vue-tsc && vite build", 17 | "preview": "vite preview" 18 | }, 19 | "dependencies": { 20 | "@element-plus/icons-vue": "^2.1.0", 21 | "@taiyuuki/utils": "^0.4.6", 22 | "@vueuse/head": "^1.3.1", 23 | "axios": "^1.5.1", 24 | "element-plus": "^2.3.14", 25 | "nes-vue": "^1.7.3", 26 | "nipplejs": "^0.10.1", 27 | "pinia": "^2.1.6", 28 | "pinia-plugin-persistedstate": "^2.4.0", 29 | "vue": "^3.3.4", 30 | "vue-router": "^4.2.5" 31 | }, 32 | "devDependencies": { 33 | "@iconify-json/fluent-emoji-high-contrast": "^1.1.10", 34 | "@iconify-json/ic": "^1.1.14", 35 | "@taiyuuki/eslint-config-vue-unimport": "^0.0.8", 36 | "@taiyuuki/unocss-preset": "^0.0.4", 37 | "@types/node": "^18.18.3", 38 | "@unocss/preset-attributify": "^0.45.30", 39 | "@unocss/preset-icons": "^0.45.30", 40 | "@unocss/preset-uno": "^0.45.30", 41 | "@vitejs/plugin-vue": "^3.2.0", 42 | "autoprefixer": "^10.4.16", 43 | "eslint": "^8.50.0", 44 | "eslint-plugin-import": "^2.28.1", 45 | "postcss": "^8.4.31", 46 | "postcss-html": "^1.5.0", 47 | "sass": "^1.69.0", 48 | "stylelint": "^14.16.1", 49 | "stylelint-config-rational-order": "^0.1.2", 50 | "stylelint-config-recommended-scss": "^8.0.0", 51 | "stylelint-config-recommended-vue": "^1.5.0", 52 | "stylelint-config-standard": "^29.0.0", 53 | "stylelint-config-standard-scss": "^6.1.0", 54 | "stylelint-scss": "^4.7.0", 55 | "typescript": "^4.9.5", 56 | "unocss": "^0.45.30", 57 | "unplugin-auto-import": "^0.11.5", 58 | "unplugin-vue-components": "^0.22.12", 59 | "vite": "^3.2.7", 60 | "vite-plugin-pages": "^0.26.0", 61 | "vite-plugin-pwa": "^0.13.3", 62 | "vite-plugin-vue-layouts": "^0.7.0", 63 | "vue-tsc": "^1.8.15" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /client/src/pages/index/index.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 103 | -------------------------------------------------------------------------------- /server/src/services/roms_service.ts: -------------------------------------------------------------------------------- 1 | import { Op, fn } from 'sequelize' 2 | import { categorys_model } from '../sequelize/models/categorys_model' 3 | import { roms_model } from '../sequelize/models/roms_model' 4 | import { checkQuery, resolveRomData, resolveURL } from '../utils/query' 5 | import { imgDir, romDir } from '../server.config' 6 | 7 | async function getRomlist(cat: string, keyword: string, page: number, limit: number) { 8 | const where: Record = {} 9 | if (checkQuery(keyword)) { 10 | where.title = { 11 | [Op.like]: `%${keyword}%`, 12 | } 13 | } 14 | if (checkQuery(cat)) { 15 | where.type = cat 16 | } 17 | const result = await roms_model.findAndCountAll({ 18 | attributes: ['id', 'title', 'cover', 'image', 'language', 'type', 'source', 'comment', 'location', 'size', 'publisher', 'url'], 19 | include: { 20 | model: categorys_model, 21 | attributes: [['name', 'type']], 22 | }, 23 | offset: (+page - 1) * +limit, 24 | limit: +limit, 25 | where, 26 | }) 27 | return { 28 | result: result.rows.map(rom => { 29 | return resolveRomData(rom) 30 | }), 31 | count: result.count, 32 | } 33 | } 34 | 35 | async function getRomById(id: string | number) { 36 | const romInfo = await roms_model.findByPk(id) 37 | if (romInfo) { 38 | romInfo.url = resolveURL(romDir + romInfo.url) 39 | romInfo.image = resolveURL(imgDir + romInfo.image) 40 | romInfo.cover = resolveURL(imgDir + romInfo.cover) 41 | } 42 | return romInfo 43 | } 44 | 45 | async function getRandomList(n: string | number, cat: string, ignore: string) { 46 | const where: Record = {} 47 | if (checkQuery(cat)) { 48 | where.type = cat 49 | } 50 | if (checkQuery(ignore)) { 51 | where.id = { [Op.ne]: ignore } 52 | } 53 | const result = await roms_model.findAll({ 54 | attributes: ['id', 'title', 'cover', 'image', 'language', 'type', 'source', 'comment', 'location', 'size', 'publisher', 'url'], 55 | include: { 56 | model: categorys_model, 57 | attributes: [['name', 'type']], 58 | }, 59 | order: [[fn('RANDOM'), 'ASC']], 60 | offset: 1, 61 | limit: +n, 62 | where, 63 | }) 64 | return result.map(rom => { 65 | return resolveRomData(rom) 66 | }) 67 | } 68 | 69 | async function getSuggestions(keyword: string) { 70 | const result = await roms_model.findAll({ 71 | attributes: ['id', 'title', 'cover'], 72 | where: { 73 | title: { 74 | [Op.like]: `%${keyword}%`, 75 | }, 76 | }, 77 | }) 78 | return result 79 | } 80 | 81 | export { getRomlist, getRomById, getRandomList, getSuggestions } 82 | -------------------------------------------------------------------------------- /client/src/components/SearchInput.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 101 | 102 | 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

在线红白机游戏

2 | 3 |

4 | vue versionexpress versionnes-vue version 5 | element-plus version 6 |

7 | 8 |

9 | 基于vue3 + express的在线FC(NES)🎮游戏项目。 10 |

11 | 12 | ## 功能 13 | 14 | 在线玩FC游戏,一共约400个游戏,全中文版。 15 | 16 | 前端框架:vue3,后端框架:express。 17 | 18 | 所有游戏资料、图片提取自`OfflineList`。 19 | 20 | * 设置主题色 21 | * 黑暗模式 22 | * 有限的支持移动端 23 | * 游戏分类 24 | * 搜索 25 | * 支持本地ROM 26 | * 支持双人 27 | * 支持保存和读取,每个游戏默认提供三个存档位。 28 | * 自定义按键 29 | * 支持手柄 30 | * 截图 31 | * 全屏 32 | * 支持PWA模式 33 | 34 | 35 |

36 | 预览图1> 37 | 预览图2 38 |

39 | 40 | ## 技术栈 41 | 42 | ### 前端 43 | 44 | * 框架:`vue3` 45 | * 构建工具:`vite` 46 | * FC模拟器组件:[taiyuuki/nes-vue](https://github.com/taiyuuki/nes-vue) 47 | * 组件库:`element-plus` 48 | * 类型检测:`typescript` 49 | * 前后端交互:`axios` 50 | * CSS预编译:`scss` 51 | * 代码格式:`eslint` `stylelint` 52 | * `vue3`生态 53 | * `vue-router` 54 | * `pinia` 55 | * `pinia-plugin-persistedstate`:pinia持久化插件 56 | * `vite`插件 57 | * `unocss`:CSS原子类生产 58 | * `unplugin-auto-import`:自动导入API 59 | * `unplugin-vue-components`:自动导入组件 60 | * `vite-plugin-pages`:基于文件自动创建路由 61 | * `vite-plugin-vue-layouts`:自动创建根路由 62 | * `vite-plugin-pwa`:PWA模式 63 | 64 | ### 后端 65 | 66 | * 框架:`express` 67 | * 数据库:`sqlite3` 68 | * 数据库驱动: `Sequelize` 69 | * 类型检测:`typescript` 70 | * 代码格式:`eslint` 71 | * 打包:`tsup` 72 | 73 | 接口详情:[nes-web/server](../../tree/main/server) 74 | 75 | ### 静态资源 76 | 77 | 像素字体:[SolidZORO/zpix-pixel-font](https://github.com/SolidZORO/zpix-pixel-font) 78 | 79 | ## 项目运行 80 | 81 | 项目目录 82 | 83 | ```bash 84 | nes-web 85 | ├─client 前端 86 | └─server 后端 87 | ``` 88 | 89 | 前端和后端需要分别安装依赖,前端包管理器`yarn`,后端包管理器`pnpm`。 90 | 91 | 后端需要的游戏ROM、图片等静态资源,我单独打包放在[release](../../releases/download/v0.0.1/roms.zip)里,下载、解压后将roms文件夹放在server文件夹内即可。 92 | 93 | ### 启动服务端 94 | 95 | 安装依赖 96 | 97 | ```shell 98 | pnpm install 99 | ``` 100 | 101 | #### node 102 | 103 | 用node运行`dist`目录下的`index.js`即可。 104 | 105 | ```shell 106 | node dist/index.js 107 | ``` 108 | 109 | #### pm2 110 | 111 | 推荐使用`pm2`: 112 | 113 | 安装pm2 114 | 115 | ```shell 116 | npm i pm2 -g 117 | ``` 118 | 119 | 启动服务 120 | 121 | ```shell 122 | pm2 start dist/index.js --watch 123 | ``` 124 | 125 | ### 前端运行 126 | 127 | 安装依赖 128 | 129 | ```shell 130 | yarn install 131 | ``` 132 | 133 | 运行 134 | 135 | ```shell 136 | yarn dev 137 | ``` 138 | -------------------------------------------------------------------------------- /client/src/axios/index.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosInstance, AxiosStatic } from 'axios' 2 | import axios from 'axios' 3 | import type { App } from 'vue' 4 | import { config } from 'src/client.config' 5 | import { isNotEmptyString } from 'src/utils' 6 | import { errorNotify } from 'src/utils/notify' 7 | 8 | declare module 'vue' { 9 | interface ComponentCustomProperties { 10 | $axios: AxiosStatic 11 | $api: AxiosInstance 12 | } 13 | } 14 | 15 | interface GameSearchOption { 16 | cat: string 17 | keyword: string 18 | page: number 19 | limit: number 20 | } 21 | 22 | export const api = axios.create({ 23 | baseURL: config.baseURL, 24 | timeout: 12000, 25 | }) 26 | 27 | api.interceptors.response.use((response) => { 28 | if (response.data.TimeOutFlag) { 29 | errorNotify('请求超时,请稍后重试。') 30 | } 31 | return response 32 | }, (err) => { 33 | errorNotify('服务器无响应,请稍后重试。') 34 | console.error(err) 35 | }) 36 | 37 | // 请求轮播图 38 | export async function requestBanner(): Promise<{ banner: { id: string; title: string; image: string }[] }> { 39 | const { data } = await api.get('/banner') 40 | return data 41 | } 42 | 43 | // 请求游戏分类 44 | export async function requestCategory(): Promise<{ [key: string]: string }> { 45 | const { data } = await api.get('/categorys') 46 | return data.categorys 47 | } 48 | 49 | // 请求游戏列表 50 | export async function requestGameList(options: GameSearchOption) { 51 | let path = `/romlist?page=${options.page}&limit=${options.limit}` 52 | if (isNotEmptyString(options.cat)) { 53 | path += `&cat=${options.cat}` 54 | } 55 | if (isNotEmptyString(options.keyword)) { 56 | path += `&keyword=${options.keyword}` 57 | } 58 | const { data } = await api.get(path) 59 | return data 60 | } 61 | 62 | // 请求搜索建议 63 | export async function requestSuggestions(keyword: string) { 64 | const { data } = await api.get(`/suggestions?keyword=${keyword}`) 65 | return data 66 | } 67 | 68 | // 请求单个游戏 69 | export async function requestRomInfo(id: string): Promise<{ code: number; rom: RomInfo }> { 70 | // const { data } = await api.get(`/rom?id=${id}`) 71 | const { data } = await api.get(`/rom?id=${id}`) 72 | return data 73 | } 74 | 75 | /** 76 | * 请求随机游戏 77 | * @param n 数量 78 | * @param cat 分类 79 | */ 80 | export async function requestRandomList(n?: number, cat?: string, ignore?: string) { 81 | let path = '/random' 82 | if (isNotEmptyString(`${n}`)) { 83 | path += `?n=${n}` 84 | } 85 | if (isNotEmptyString(cat)) { 86 | path += `&cat=${cat}` 87 | } 88 | if (isNotEmptyString(ignore)) { 89 | path += `&ignore=${ignore}` 90 | } 91 | const { data } = await api.get(path) 92 | return data 93 | } 94 | 95 | export default { 96 | install(app: App) { 97 | app.config.globalProperties.$axios = axios 98 | app.config.globalProperties.$api = api 99 | }, 100 | } 101 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 |

在线红白机游戏

2 | 3 |

4 | express version 5 |

6 | 7 |

8 | 基于vue3 + express的在线FC(NES)🎮游戏项目,服务端。 9 |

10 | 11 | * 框架:`express` 12 | * 数据库:`sqlite3` 13 | * 数据库驱动: `Sequelize` 14 | * 类型检测:`typescript` 15 | * 代码格式:`eslint` 16 | * 打包:`tsup` 17 | 18 | ## 项目运行 19 | 20 | 游戏ROM、图片等静态资源,我单独打包放在[release](../../../releases/download/v0.0.1/roms.zip)里,下载、解压后将roms文件夹放在server文件夹内即可。 21 | 22 | ### 安装依赖 23 | 24 | ```shell 25 | pnpm install 26 | ``` 27 | 28 | ### 启动服务端 29 | 30 | #### node 31 | 32 | 使用node运行`dist/index.js`即可。 33 | 34 | ```shell 35 | node dist/index.js 36 | ``` 37 | 38 | #### pm2 39 | 40 | 推荐使用`pm2` 41 | 42 | 安装pm2 43 | 44 | ```shell 45 | npm i pm2 -g 46 | ``` 47 | 48 | 启动服务 49 | 50 | ```shell 51 | pm2 start dist/index.js --watch 52 | ``` 53 | 54 | 查看日志 55 | 56 | ```shell 57 | pm2 log 58 | ``` 59 | 60 | 查看所有服务 61 | 62 | ```shell 63 | pm2 list 64 | ``` 65 | 66 | 停止服务 67 | 68 | ```shell 69 | pm2 stop id 70 | ``` 71 | 72 | 移除服务 73 | 74 | ```shell 75 | pm2 delete id 76 | ``` 77 | 78 | 查看实时状态 79 | 80 | ```shell 81 | pm2 monit 82 | ``` 83 | 84 | ### 接口 85 | 86 | 服务器默认端口为8848,默认本地启动地址:`http://localhost:8848` 87 | 88 | 地址可以在`src/server.config.ts`中进行设置(修改后记得要`pnpm build`重新编译),前端也需要修改`src/client.config.ts`中对应的设置。 89 | 90 | #### 获取游戏分类 91 | 92 | **API: `/categorys`** 93 | 94 | 示例:`http://localhost:8848/categorys` 95 | 96 | #### 获取轮播图 97 | 98 | **API: `/banner`** 99 | 100 | #### 获取游戏列表 101 | 102 | 说明:此接口用于获取游戏列表,同时可以用作搜索。 103 | 104 | **API: `/romlist`** 105 | 106 | 可选参数: 107 | 108 | * *cat*:分类,比如`ACT`、 `STG` 109 | * *keyword*:搜索关键字 110 | * *page*:分页,默认值为1 111 | * *limit*:每页数量,默认值为20 112 | 113 | 示例:`http://localhost:8848/romlist?cat=ACT&page=2&limit=10` 114 | 115 | #### 随机获取游戏列表 116 | 117 | 说明:此接口用于获取随机N个游戏。 118 | 119 | **API: `/random`** 120 | 121 | 可选参数: 122 | 123 | * *n*:游戏数量 124 | * *cat*:分类,比如比如`ACT` 、`STG` 125 | * *ignore*:不包括某个游戏的id 126 | 127 | 示例:`http://localhost:8848/random?n=10&cat=ACT&ignore=7` 128 | 129 | #### 获取单个游戏 130 | 131 | 说明:此接口可以通过游戏id获取单个游戏完整信息。 132 | 133 | **API: `/rom`** 134 | 135 | **必选参数**: 136 | 137 | * *id*:游戏id 138 | 139 | 示例:`http://localhost:8848/rom?id=7` 140 | 141 | #### 搜索建议 142 | 143 | 说明:此接口用于给定关键词列出搜索建议。 144 | 145 | **API: `/suggestions`** 146 | 147 | **必选参数**: 148 | 149 | * *keyword*:关键词 150 | 151 | 示例:`http://localhost:8848/suggestions?keyword=洛克人` 152 | 153 | #### 静态资源 154 | 155 | 静态资源的地址会包含在获取的游戏列表中。 156 | 157 | **图片资源** 158 | 159 | **API: `http://localhost:8848/roms/img/id`** 160 | 161 | 每一个游戏都有两张图片。 162 | 163 | 例如,id为472的游戏,封面: 164 | 165 | `http://localhost:8848/roms/img/472a.png` 166 | 167 | 截图: 168 | 169 | `http://localhost:8848/roms/img/472b.png` 170 | 171 | **ROM资源** 172 | 173 | **API: `http://localhost:8848/roms/游戏名`** 174 | 175 | 例如,大金刚:`http://localhost:8848/roms/大金刚 (简)` -------------------------------------------------------------------------------- /client/src/components/TBanner.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 60 | 61 | 124 | -------------------------------------------------------------------------------- /server/source.json: -------------------------------------------------------------------------------- 1 | {"6502":true,"2434917":true,"外星科技":true,"Sachen":true,"烟山软件":true,"Idea-Tek":true,"金明":true,"吸血男爵":true,"8GUA&MS":true,"卡圣":true,"幻想":true,"鸿达":true,"樱组&MS":true,"俞亮":true,"未知":true,"Rex":true,"Puszta":true,"全崴资讯":true,"惊风":true,"Super Tone":true,"王龙&MS":true,"火星电子":true,"天空联盟":true,"小天才":true,"GXB":true,"Namco":true,"闪 纯静水&MS":true,"奇玉":true,"外星科技&姜维第二":true,"Joy Van":true,"Flow Wolf":true,"先锋卡通":true,"Justin":true,"强典":true,"TPU":true,"Peacock Wang":true,"阿渊":true,"不知所云":true,"SW":true,"勇者":true,"Future":true,"Zero Boy":true,"Cony Soft":true,"8GUA":true,"幻想&MS":true,"樱组":true,"未知&MS":true,"孔雀天":true,"先锋卡通&BonnyXie":true,"顽石":true,"Dogout":true,"先锋卡通&Necrosaro":true,"Asder":true,"MS":true,"天使":true,"Antigloss&MS":true,"高艳龙&MS":true,"酷酷小熊&MS":true,"模拟中文网&MS":true,"NeoX":true,"汉化情报站&MS":true,"高艳龙":true,"龙组":true,"Old Liu":true,"空气":true,"小滩":true,"王龙":true,"天翔&MS":true,"流心小雨":true,"张晓波":true,"Peacock Wang&MS":true,"DC":true,"两亦":true,"南晶科技":true,"外星科技&lirdrepus":true,"外星科技&KANOU":true,"Madcell":true,"Ken&XYGong&Macro":true,"恒格电子":true,"中伟":true,"全崴资讯&147201688":true,"火星电子&147201688":true,"外星科技&147201688":true,"星星科技":true,"郭元":true,"孔雀天&MS":true,"外星科技&nfzxyd":true,"MM之神":true,"姜维第二":true,"来福":true,"混沌星辰&MS":true,"烈火暴龙":true,"PKome":true,"Jeff.Ma":true,"Yurica":true,"中伟&MS":true,"黄信维":true,"奔升":true,"先锋卡通&ZYM":true,"陈大玉&MM之神":true,"王大可":true,"台北裕威":true,"maoweimam":true,"Antigloss":true,"外星游戏大厅":true,"靳大治":true,"laopix":true,"死神DIY":true,"小霸王":true,"惊风&空气":true,"MS&星夜之幻":true,"金巴比伦":true,"烟山科技":true,"cslrxyz":true,"外星科技&MS":true,"勒大治&MS":true,"先锋卡通&MS":true,"ENTA":true,"Rinkaku":true,"老代":true,"油气车":true,"数奇玉":true,"宣云&张晓波&蓝一冬":true,"宣云&张晓波":true,"晶碁电子":true,"酷哥电子":true,"凯程电子":true,"小鬼混":true,"叶枫":true,"外星科技&qqgba":true,"Gouder":true,"三协资讯":true,"JY Company":true,"天苑软件":true,"madcell":true,"凌捷":true,"maxzhou88":true,"丰利":true,"dfqshy":true,"荣丰":true,"Bit":true,"Nokoh":true,"东生":true,"Shinwa":true,"菜菜学堂":true,"星空":true,"罗云":true,"Nineswords":true,"dragon2snow":true,"无极":true,"小笨笨":true,"九班":true,"星夜之幻":true,"冰组":true,"九班&KasuraJ":true,"封印记忆":true,"热血的鱼缸":true,"外星科技&WXN":true,"年代科技":true,"F0REVERD":true,"未知&WXN":true,"hjind1213":true,"高昇达":true,"catcatcat":true,"AXI":true,"快乐的龙":true,"三佳":true,"太阳王子葵新伍":true,"天蝎之忆":true,"晶科泰":true,"奔力":true,"未名":true,"南通科技":true,"同能网":true,"爱尔普":true,"振华":true,"小视霸":true,"3DM":true,"muxly":true,"东达":true,"科王":true,"泽诚":true,"科达":true,"新科":true,"未名&法兰克飞鸟":true,"TCL":true,"先达电子":true,"霸王的大陆":true,"NT":true,"汉化你妹":true,"未名&汉化你妹":true,"earth幻灭":true,"八德":true,"水火":true,"BiTe":true,"Jixun":true,"DMG":true,"FlameCyclone":true,"Advance":true,"高伟":true,"谈魈疯生":true,"外星科技&Lirdrepus":true,"溪流满月&KasuraJ":true,"KasuraJ":true,"雷精灵":true,"DMG&Advance":true,"外星科技&心伤谁知":true,"外星科技&庞先生&寒雪使者":true,"寒雪使者":true,"春华秋实":true,"少年不知愁":true,"独孤九剑":true,"大华电脑":true,"外星科技&SSforME":true,"AXI&庞先生":true,"冰组&庞先生":true,"星空&庞先生":true,"外星科技&庞先生":true,"外星科技&willzyj":true,"逆游的五彩鱼":true,"外星科技&晚风轻起":true,"夜幕星辰":true,"游戏天地":true,"kwjxqy":true,"外星科技&DG小火":true,"心伤谁知":true,"c1faceb0":true,"厌鱼猫猫":true,"网一晓":true,"外星科技&Vanyogin":true,"金巴比伦&Vanyogin":true,"小古":true,"verkkars":true,"翼势力":true,"先锋卡通&leesoft":true,"先锋卡通&星夜之幻":true,"LSP":true,"御酒探花":true,"虫虫":true,"虫儿":true,"先锋卡通&kook":true,"asiwish":true,"热血的鱼缸&asiwish":true} -------------------------------------------------------------------------------- /client/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export function getContrastColor(hexcolor: string) { 2 | const r = parseInt(hexcolor.substring(1, 2), 16) 3 | const g = parseInt(hexcolor.substring(3, 4), 16) 4 | const b = parseInt(hexcolor.substring(5, 6), 16) 5 | const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000 6 | return yiq >= 10 ? 'black' : 'white' 7 | } 8 | 9 | export function isNotNull(target: T): target is NonNullable { 10 | return target !== void 0 && target !== null 11 | } 12 | 13 | export function isNotEmptyString(s: T): s is Exclude { 14 | if (typeof s === 'string') { 15 | return s.trim() !== '' 16 | } 17 | return isNotNull(s) 18 | } 19 | 20 | export function getKeys>(c: T) { 21 | return Object.keys(c) as UnionToTuple> 22 | } 23 | 24 | export function getFiler(arr: T[]) { 25 | return arr.filter(item => isNotNull(item)) as NonNullable[] 26 | } 27 | 28 | export function between(v: number, min: number, max: number) { 29 | return max <= min 30 | ? min 31 | : Math.min(max, Math.max(min, v)) 32 | } 33 | 34 | export function isBetween(v: number, min: number, max: number) { 35 | return v >= min && v <= max 36 | } 37 | 38 | export function inc(p: number, amount?: number) { 39 | if (typeof amount !== 'number') { 40 | if (p < 25) { 41 | amount = (Math.random() * 3) + 3 42 | } 43 | else if (p < 65) { 44 | amount = Math.random() * 3 45 | } 46 | else if (p < 85) { 47 | amount = Math.random() * 2 48 | } 49 | else if (p < 99) { 50 | amount = 0.6 51 | } 52 | else { 53 | amount = 0 54 | } 55 | } 56 | return between(p + amount, 0, 100) 57 | } 58 | 59 | export function stopDefault(e: DragEvent) { 60 | e.preventDefault() 61 | if (e.dataTransfer) { 62 | e.dataTransfer.dropEffect = 'move' 63 | } 64 | } 65 | 66 | export function getNow() { 67 | function digitComplement(n: number) { 68 | return String(n).length === 1 ? `0${n}` : String(n) 69 | } 70 | const time = new Date(Date.now()) 71 | let result = time.getFullYear() + '年' 72 | result += (time.getMonth() + 1 + '月') 73 | result += (time.getDate() + '日 ') 74 | result += (digitComplement(time.getHours()) + ':') 75 | result += (digitComplement(time.getMinutes()) + ':') 76 | result += (digitComplement(time.getSeconds())) 77 | return result 78 | } 79 | 80 | export function imageToBase64(img: HTMLImageElement | undefined) { 81 | const cvs = document.createElement('canvas') 82 | cvs.width = 48 83 | cvs.height = 45 84 | const ctx = cvs.getContext('2d') 85 | if (isNotNull(img) && isNotNull(ctx)) { 86 | ctx.drawImage(img, 0, 0, cvs.width, cvs.height) 87 | } 88 | return cvs.toDataURL('image/jpeg') 89 | } 90 | 91 | export const setStorage = (key: string, value: T) => { 92 | localStorage.setItem(key, JSON.stringify(value)) 93 | } 94 | 95 | export const getStorage = (key: string, empty: T) => { 96 | const data = localStorage.getItem(key) 97 | return isNotNull(data) ? JSON.parse(data) : empty 98 | } 99 | 100 | export const removeStorage = (key: string) => { 101 | localStorage.removeItem(key) 102 | } 103 | -------------------------------------------------------------------------------- /server/src/routers/rom_router.ts: -------------------------------------------------------------------------------- 1 | import { Router as router } from 'express' 2 | import { dispatchResponse, sendEmpty } from '../utils/response' 3 | 4 | import { checkQuery, resolveURL } from '../utils/query' 5 | import { imgDir } from '../server.config' 6 | import { getRandomList, getRomById, getRomlist, getSuggestions } from '../services/roms_service' 7 | 8 | const roms = router() 9 | 10 | // 获取游戏ROM列表 11 | // /romlist?cat=xxx&keyword=xxx&page=xxx&limit=xxx 12 | roms.get('/romlist', async (req, res) => { 13 | let { cat, keyword, page, limit } = req.query as Record 14 | cat ??= '' 15 | keyword ??= '' 16 | page ??= '1' 17 | limit ??= '20' 18 | await dispatchResponse(async () => { 19 | const list = await getRomlist(cat, keyword, +page, +limit) 20 | res.send({ 21 | code: 200, result: list.result, count: list.count, 22 | }) 23 | }, res) 24 | }) 25 | 26 | // 随机获取N个游戏 27 | // /random?n=xxx&cat=xxx&ignore=xxx 28 | roms.get('/random', async (req, res) => { 29 | let { n, cat, ignore } = req.query as Record 30 | n ??= '8' 31 | await dispatchResponse(async () => { 32 | const result = await getRandomList(n, cat, ignore) 33 | res.send({ code: 200, result }) 34 | }, res) 35 | }) 36 | 37 | // 根据id获取单个ROM信息 38 | // /rom?id=xxx 39 | roms.get('/rom', async (req, res) => { 40 | const id = req.query.id as string 41 | if (!checkQuery(id)) { 42 | sendEmpty(res, 'id') 43 | return 44 | } 45 | await dispatchResponse(async () => { 46 | const rom = await getRomById(id) 47 | if (rom) { 48 | res.send({ 49 | code: 200, 50 | rom, 51 | }) 52 | } 53 | else { 54 | res.send({ code: 400 }) 55 | } 56 | }, res) 57 | }) 58 | 59 | // 搜索建议 60 | // /suggestions?keyword=xxx 61 | roms.get('/suggestions', async (req, res) => { 62 | const keyword = req.query.keyword as string 63 | if (checkQuery(keyword)) { 64 | await dispatchResponse(async () => { 65 | const result = await getSuggestions(keyword) 66 | if (result.length > 0) { 67 | const suggestions = result.map(game => { 68 | return { 69 | id: game.id, 70 | value: game.title, 71 | cover: resolveURL(imgDir + game.cover), 72 | } 73 | }) 74 | res.send({ 75 | code: 200, 76 | suggestions, 77 | }) 78 | } 79 | else { 80 | res.send({ code: 0 }) 81 | } 82 | }, res) 83 | } 84 | else { 85 | sendEmpty(res, 'keyword') 86 | return 87 | } 88 | }) 89 | 90 | // 根据id删除ROM 91 | // /delete?id=xxx 92 | roms.delete('/delete', async (req, res) => { 93 | const id = req.query.id as string 94 | if (!checkQuery(id)) { 95 | sendEmpty(res, 'id') 96 | return 97 | } 98 | await dispatchResponse(async () => { 99 | const rom = await getRomById(id) 100 | if (rom) { 101 | rom.destroy() 102 | res.send({ code: 200 }) 103 | } 104 | else { 105 | res.send({ code: 400 }) 106 | } 107 | }, res) 108 | }) 109 | 110 | export default roms 111 | -------------------------------------------------------------------------------- /client/src/components/InnerLoading.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 30 | 31 | 174 | -------------------------------------------------------------------------------- /client/src/components.d.ts: -------------------------------------------------------------------------------- 1 | // generated by unplugin-vue-components 2 | // We suggest you to commit this file into source control 3 | // Read more: https://github.com/vuejs/core/pull/3399 4 | import '@vue/runtime-core' 5 | 6 | export {} 7 | 8 | declare module '@vue/runtime-core' { 9 | export interface GlobalComponents { 10 | AjaxBar: typeof import('./components/AjaxBar.vue')['default'] 11 | BannerCover: typeof import('./components/BannerCover.vue')['default'] 12 | BannerSkeletion: typeof import('./components/skeleton/BannerSkeletion.vue')['default'] 13 | CardSkeletion: typeof import('./components/skeleton/CardSkeletion.vue')['default'] 14 | ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete'] 15 | ElButton: typeof import('element-plus/es')['ElButton'] 16 | ElCarousel: typeof import('element-plus/es')['ElCarousel'] 17 | ElCarouselItem: typeof import('element-plus/es')['ElCarouselItem'] 18 | ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] 19 | ElContainer: typeof import('element-plus/es')['ElContainer'] 20 | ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] 21 | ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] 22 | ElDialog: typeof import('element-plus/es')['ElDialog'] 23 | ElDrawer: typeof import('element-plus/es')['ElDrawer'] 24 | ElHeader: typeof import('element-plus/es')['ElHeader'] 25 | ElMain: typeof import('element-plus/es')['ElMain'] 26 | ElPageHeader: typeof import('element-plus/es')['ElPageHeader'] 27 | ElPagination: typeof import('element-plus/es')['ElPagination'] 28 | ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] 29 | ElSkeletonItem: typeof import('element-plus/es')['ElSkeletonItem'] 30 | ElSwitch: typeof import('element-plus/es')['ElSwitch'] 31 | ElTooltip: typeof import('element-plus/es')['ElTooltip'] 32 | GameCard: typeof import('./components/GameCard.vue')['default'] 33 | GameCover: typeof import('./components/GameCover.vue')['default'] 34 | GameEmulator: typeof import('./components/GameEmulator.vue')['default'] 35 | HeadDrawer: typeof import('./components/HeadDrawer.vue')['default'] 36 | IconInner: typeof import('./components/Icon/IconInner.vue')['default'] 37 | IconMbo: typeof import('./components/Icon/IconMbo.vue')['default'] 38 | IconMob: typeof import('./components/Icon/IconMob.vue')['default'] 39 | IconOutside: typeof import('./components/Icon/IconOutside.vue')['default'] 40 | InnerLoading: typeof import('./components/InnerLoading.vue')['default'] 41 | KeyboardOption: typeof import('./components/KeyboardOption.vue')['default'] 42 | KeySquare: typeof import('./components/KeySquare.vue')['default'] 43 | MainFooter: typeof import('./components/MainFooter.vue')['default'] 44 | MainHeader: typeof import('./components/MainHeader.vue')['default'] 45 | NBtn: typeof import('./components/button/NBtn.vue')['default'] 46 | NButton: typeof import('./components/button/NButton.vue')['default'] 47 | OBtn: typeof import('./components/button/OBtn.vue')['default'] 48 | RecomBox: typeof import('./components/RecomBox.vue')['default'] 49 | RouterLink: typeof import('vue-router')['RouterLink'] 50 | RouterView: typeof import('vue-router')['RouterView'] 51 | SearchInput: typeof import('./components/SearchInput.vue')['default'] 52 | TBanner: typeof import('./components/TBanner.vue')['default'] 53 | TestVue: typeof import('./components/TestVue.vue')['default'] 54 | VolumeKnob: typeof import('./components/VolumeKnob.vue')['default'] 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /client/public/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 31 | 34 | 38 | 40 | 42 | 46 | 47 | 48 | 50 | 52 | 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /client/src/css/app.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary: #1976d2; 3 | --secondary: #0bea0b; 4 | --accent: #9c27b0; 5 | --dark: #1d1d1d; 6 | --dark-page: #121212; 7 | --positive: #21ba45; 8 | --negative: #c10015; 9 | --info: #31ccec; 10 | --warning: #f2c037; 11 | 12 | overflow-x: hidden; 13 | overflow-y: auto; 14 | } 15 | 16 | $fonts: system-ui, 17 | -apple-system, 18 | "Segoe UI", 19 | roboto, 20 | emoji, 21 | helvetica, 22 | arial, 23 | sans-serif; 24 | 25 | @font-face { 26 | font-family: Emoji; 27 | src: 28 | local("Apple Color Emojiji"), 29 | local("Segoe Emoji"), 30 | local("Noto Color Emoji"); 31 | unicode-range: U+1F000-1F644, U+203C-3299; 32 | } 33 | 34 | @font-face { 35 | font-family: zpix; 36 | src: url("fonts/zpix.woff2"); 37 | } 38 | 39 | .no-zpix { 40 | font-family: $fonts; 41 | } 42 | 43 | body { 44 | box-sizing: border-box; 45 | width: 100vw !important; 46 | margin: 0; 47 | padding: 0; 48 | overflow: hidden; 49 | font-family: 50 | zpix, 51 | $fonts; 52 | } 53 | 54 | :root body { 55 | position: absolute !important; 56 | } 57 | 58 | html { 59 | --base: #fff; 60 | --theme: #f5f7fa; 61 | --fcolor: #000; 62 | --grey: #c0c4c3; 63 | 64 | -webkit-overflow-scrolling: touch; 65 | scrollbar-color: var(--primary) !important; 66 | scrollbar-width: thin; 67 | 68 | // scrollbar-gutter: stable; 69 | // scroll-behavior: smooth; 70 | 71 | overflow-y: scroll; 72 | 73 | ::-webkit-scrollbar { 74 | width: 8px; 75 | height: 8px; 76 | background-color: transparent !important; 77 | } 78 | 79 | ::-webkit-scrollbar-thumb { 80 | background-color: var(--primary) !important; 81 | border-radius: 4px; 82 | } 83 | } 84 | 85 | html.dark { 86 | --base: #000; 87 | --theme: #262727; 88 | --fcolor: #fffef9; 89 | --grey: #132c33; 90 | } 91 | 92 | @layer { 93 | h1 { 94 | font-size: 2.5rem; 95 | } 96 | 97 | h2 { 98 | font-size: 2.2rem; 99 | } 100 | 101 | h3 { 102 | font-size: 2rem; 103 | } 104 | 105 | h4 { 106 | font-size: 1.8rem; 107 | } 108 | 109 | h5 { 110 | font-size: 1.5rem; 111 | } 112 | 113 | h6 { 114 | font-size: 1.2rem; 115 | } 116 | 117 | div, 118 | p { 119 | margin: 0; 120 | padding: 0; 121 | } 122 | 123 | i { 124 | font-style: normal; 125 | } 126 | 127 | img::before { 128 | position: absolute; 129 | top: 0; 130 | left: 0; 131 | width: 100%; 132 | height: 100%; 133 | color: transparent; 134 | background: 135 | #f5f5f5 136 | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='512' height='512' viewBox='0 0 512 512'%3E%3Cpath fill='rgb(220, 220, 220)' d='M40 472h432V312h-32v128H72V312H40v160zm0-206.245l49.373 25.437l53.82-46.829l56.159 50.528L256 247.052l56.648 47.839l56.159-50.528l53.82 46.829L472 265.755V40H40ZM72 72h368v174.244l-12.738 6.564l-58.809-51.171l-56.471 50.806L256 205.167l-55.982 47.276l-56.471-50.806l-58.809 51.171L72 246.244Z'/%3E%3C/svg%3E") 137 | no-repeat center / 50% 50%; 138 | content: ""; 139 | } 140 | 141 | img::after { 142 | position: absolute; 143 | bottom: 0; 144 | left: 0; 145 | width: 100%; 146 | overflow: hidden; 147 | color: var(--primary-front); 148 | font-size: 12px; 149 | line-height: 2; 150 | white-space: nowrap; 151 | text-align: center; 152 | text-overflow: ellipsis; 153 | background-color: #0006; 154 | content: attr(alt); 155 | } 156 | 157 | a { 158 | text-decoration: none !important; 159 | 160 | &:link { 161 | color: inherit; 162 | } 163 | 164 | &:visited { 165 | color: inherit; 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import path from 'path' 4 | import pages from 'vite-plugin-pages' 5 | import layouts from 'vite-plugin-vue-layouts' 6 | import unocss from 'unocss/vite' 7 | import autoAPIs from 'unplugin-auto-import/vite' 8 | import autoComponents from 'unplugin-vue-components/vite' 9 | import { ElementPlusResolver as elementPlus } from 'unplugin-vue-components/resolvers' 10 | import { VitePWA as pwa } from 'vite-plugin-pwa' 11 | 12 | function resolve(dir: string) { 13 | return path.join(__dirname, dir) 14 | } 15 | 16 | // https://vitejs.dev/config/ 17 | export default defineConfig({ 18 | base: './', 19 | resolve: { 20 | alias: { 21 | '@': resolve('src'), 22 | 'src': resolve('src'), 23 | 'components': resolve('src/components'), 24 | 'layouts': resolve('src/layouts'), 25 | 'pages': resolve('src/pages'), 26 | 'router': resolve('src/router'), 27 | 'stores': resolve('src/stores'), 28 | }, 29 | }, 30 | plugins: [ 31 | vue(), 32 | unocss(), 33 | pages({ 34 | extensions: ['vue'], 35 | extendRoute(route) { 36 | return { 37 | ...route, 38 | meta: { auth: true }, 39 | } 40 | }, 41 | }), 42 | layouts({ defaultLayout: 'MainLayout' }), 43 | autoComponents({ 44 | dts: 'src/components.d.ts', 45 | resolvers: [elementPlus()], 46 | }), 47 | autoAPIs({ 48 | imports: [ 49 | 'vue', 50 | 'pinia', 51 | 'vue-router', 52 | '@vueuse/head', 53 | ], 54 | dts: 'src/auto-imports.d.ts', 55 | resolvers: [elementPlus()], 56 | }), 57 | pwa({ 58 | registerType: 'autoUpdate', 59 | includeAssets: ['icons/safari-pinned-tab.svg'], 60 | manifest: { 61 | name: '在线红白机游戏', 62 | short_name: '在线NES游戏', 63 | theme_color: '#ffffff', 64 | icons: [ 65 | { 66 | src: 'icons/android-chrome-192x192.png', 67 | sizes: '192x192', 68 | type: 'image/png', 69 | }, 70 | { 71 | src: 'icons/apple-touch-icon.png', 72 | sizes: '180x180', 73 | type: 'image/png', 74 | }, 75 | { 76 | src: 'icons/android-chrome-512x512.png', 77 | sizes: '512x512', 78 | type: 'image/png', 79 | }, 80 | { 81 | src: 'icons/mstile-150x150.png', 82 | sizes: '150x150', 83 | type: 'image/png', 84 | }, 85 | { 86 | src: 'icons/favicon-16x16.png', 87 | sizes: '16x16', 88 | type: 'image/png', 89 | }, 90 | { 91 | src: 'icons/favicon-32x32.png', 92 | sizes: '32x32', 93 | type: 'image/png', 94 | }, 95 | ], 96 | }, 97 | devOptions: { enabled: false }, 98 | }), 99 | ], 100 | server: { port: 8799 }, 101 | build: { 102 | rollupOptions: { 103 | output: { 104 | chunkFileNames: 'js/[name]-[hash].js', 105 | entryFileNames: 'js/[name]-[hash].js', 106 | assetFileNames: '[ext]/[name]-[hash].[ext]', 107 | }, 108 | }, 109 | }, 110 | }) 111 | -------------------------------------------------------------------------------- /client/src/components/VolumeKnob.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 137 | 138 | 164 | -------------------------------------------------------------------------------- /client/src/components/AjaxBar.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 149 | 150 | 161 | -------------------------------------------------------------------------------- /client/src/components/KeySquare.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 82 | 83 | 139 | -------------------------------------------------------------------------------- /client/src/auto-imports.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by 'unplugin-auto-import' 2 | export {} 3 | declare global { 4 | const EffectScope: typeof import('vue')['EffectScope'] 5 | const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate'] 6 | const computed: typeof import('vue')['computed'] 7 | const createApp: typeof import('vue')['createApp'] 8 | const createPinia: typeof import('pinia')['createPinia'] 9 | const customRef: typeof import('vue')['customRef'] 10 | const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] 11 | const defineComponent: typeof import('vue')['defineComponent'] 12 | const defineStore: typeof import('pinia')['defineStore'] 13 | const effectScope: typeof import('vue')['effectScope'] 14 | const getActivePinia: typeof import('pinia')['getActivePinia'] 15 | const getCurrentInstance: typeof import('vue')['getCurrentInstance'] 16 | const getCurrentScope: typeof import('vue')['getCurrentScope'] 17 | const h: typeof import('vue')['h'] 18 | const inject: typeof import('vue')['inject'] 19 | const isProxy: typeof import('vue')['isProxy'] 20 | const isReactive: typeof import('vue')['isReactive'] 21 | const isReadonly: typeof import('vue')['isReadonly'] 22 | const isRef: typeof import('vue')['isRef'] 23 | const mapActions: typeof import('pinia')['mapActions'] 24 | const mapGetters: typeof import('pinia')['mapGetters'] 25 | const mapState: typeof import('pinia')['mapState'] 26 | const mapStores: typeof import('pinia')['mapStores'] 27 | const mapWritableState: typeof import('pinia')['mapWritableState'] 28 | const markRaw: typeof import('vue')['markRaw'] 29 | const nextTick: typeof import('vue')['nextTick'] 30 | const onActivated: typeof import('vue')['onActivated'] 31 | const onBeforeMount: typeof import('vue')['onBeforeMount'] 32 | const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave'] 33 | const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate'] 34 | const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] 35 | const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] 36 | const onDeactivated: typeof import('vue')['onDeactivated'] 37 | const onErrorCaptured: typeof import('vue')['onErrorCaptured'] 38 | const onMounted: typeof import('vue')['onMounted'] 39 | const onRenderTracked: typeof import('vue')['onRenderTracked'] 40 | const onRenderTriggered: typeof import('vue')['onRenderTriggered'] 41 | const onScopeDispose: typeof import('vue')['onScopeDispose'] 42 | const onServerPrefetch: typeof import('vue')['onServerPrefetch'] 43 | const onUnmounted: typeof import('vue')['onUnmounted'] 44 | const onUpdated: typeof import('vue')['onUpdated'] 45 | const provide: typeof import('vue')['provide'] 46 | const reactive: typeof import('vue')['reactive'] 47 | const readonly: typeof import('vue')['readonly'] 48 | const ref: typeof import('vue')['ref'] 49 | const resolveComponent: typeof import('vue')['resolveComponent'] 50 | const resolveDirective: typeof import('vue')['resolveDirective'] 51 | const setActivePinia: typeof import('pinia')['setActivePinia'] 52 | const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix'] 53 | const shallowReactive: typeof import('vue')['shallowReactive'] 54 | const shallowReadonly: typeof import('vue')['shallowReadonly'] 55 | const shallowRef: typeof import('vue')['shallowRef'] 56 | const storeToRefs: typeof import('pinia')['storeToRefs'] 57 | const toRaw: typeof import('vue')['toRaw'] 58 | const toRef: typeof import('vue')['toRef'] 59 | const toRefs: typeof import('vue')['toRefs'] 60 | const triggerRef: typeof import('vue')['triggerRef'] 61 | const unref: typeof import('vue')['unref'] 62 | const useAttrs: typeof import('vue')['useAttrs'] 63 | const useCssModule: typeof import('vue')['useCssModule'] 64 | const useCssVars: typeof import('vue')['useCssVars'] 65 | const useHead: typeof import('@vueuse/head')['useHead'] 66 | const useLink: typeof import('vue-router')['useLink'] 67 | const useRoute: typeof import('vue-router')['useRoute'] 68 | const useRouter: typeof import('vue-router')['useRouter'] 69 | const useSlots: typeof import('vue')['useSlots'] 70 | const watch: typeof import('vue')['watch'] 71 | const watchEffect: typeof import('vue')['watchEffect'] 72 | const watchPostEffect: typeof import('vue')['watchPostEffect'] 73 | const watchSyncEffect: typeof import('vue')['watchSyncEffect'] 74 | } 75 | -------------------------------------------------------------------------------- /client/src/pages/index/GamePlayer.vue: -------------------------------------------------------------------------------- 1 | 72 | 73 | 155 | -------------------------------------------------------------------------------- /client/src/pages/index/GameList.vue: -------------------------------------------------------------------------------- 1 | 67 | 68 | 176 | 177 | 196 | -------------------------------------------------------------------------------- /client/src/components/MainHeader.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 181 | 182 | 208 | -------------------------------------------------------------------------------- /client/src/options/keyboard.ts: -------------------------------------------------------------------------------- 1 | export const keys_1 = [{ 2 | code: 'Backquote', size: 40, name: '`', 3 | }, 4 | { 5 | code: 'Digit1', size: 40, name: '1', 6 | }, 7 | { 8 | code: 'Digit2', size: 40, name: '2', 9 | }, 10 | { 11 | code: 'Digit3', size: 40, name: '3', 12 | }, 13 | { 14 | code: 'Digit4', size: 40, name: '4', 15 | }, 16 | { 17 | code: 'Digit5', size: 40, name: '5', 18 | }, 19 | { 20 | code: 'Digit6', size: 40, name: '6', 21 | }, 22 | { 23 | code: 'Digit7', size: 40, name: '7', 24 | }, 25 | { 26 | code: 'Digit8', size: 40, name: '8', 27 | }, 28 | { 29 | code: 'Digit9', size: 40, name: '9', 30 | }, 31 | { 32 | code: 'Digit0', size: 40, name: '0', 33 | }, 34 | { 35 | code: 'Minus', size: 40, name: '-', 36 | }, 37 | { 38 | code: 'Equal', size: 40, name: '=', 39 | }, 40 | { 41 | code: 'Backspace', size: 80, name: 'Bksp', 42 | }, 43 | { 44 | code: 'Numpad7', size: 40, name: '7', 45 | }, 46 | { 47 | code: 'Numpad8', size: 40, name: '8', 48 | }, 49 | { 50 | code: 'Numpad9', size: 40, name: '9', 51 | }, 52 | ] 53 | 54 | export const keys_2 = [{ 55 | code: 'Tab', size: 80, name: 'Tab', 56 | }, 57 | { 58 | code: 'KeyQ', size: 40, name: 'Q', 59 | }, 60 | { 61 | code: 'KeyW', size: 40, name: 'W', 62 | }, 63 | { 64 | code: 'KeyE', size: 40, name: 'E', 65 | }, 66 | { 67 | code: 'KeyR', size: 40, name: 'R', 68 | }, 69 | { 70 | code: 'KeyT', size: 40, name: 'T', 71 | }, 72 | { 73 | code: 'KeyY', size: 40, name: 'Y', 74 | }, 75 | { 76 | code: 'KeyU', size: 40, name: 'U', 77 | }, 78 | { 79 | code: 'KeyI', size: 40, name: 'I', 80 | }, 81 | { 82 | code: 'KeyO', size: 40, name: 'O', 83 | }, 84 | { 85 | code: 'KeyP', size: 40, name: 'P', 86 | }, 87 | { 88 | code: 'BracketLeft', size: 40, name: '[', 89 | }, 90 | { 91 | code: 'BracketRight', size: 40, name: ']', 92 | }, 93 | { 94 | code: 'Backslash', size: 40, name: '\\', 95 | }, 96 | { 97 | code: 'Numpad4', size: 40, name: '4', 98 | }, 99 | { 100 | code: 'Numpad5', size: 40, name: '5', 101 | }, 102 | { 103 | code: 'Numpad6', size: 40, name: '6', 104 | }, 105 | ] 106 | 107 | export const keys_3 = [{ 108 | code: 'CapsLock', size: 90, name: 'Caps', 109 | }, 110 | { 111 | code: 'KeyA', size: 40, name: 'A', 112 | }, 113 | { 114 | code: 'KeyS', size: 40, name: 'S', 115 | }, 116 | { 117 | code: 'KeyD', size: 40, name: 'D', 118 | }, 119 | { 120 | code: 'KeyF', size: 40, name: 'F', 121 | }, 122 | { 123 | code: 'KeyG', size: 40, name: 'G', 124 | }, 125 | { 126 | code: 'KeyH', size: 40, name: 'H', 127 | }, 128 | { 129 | code: 'KeyJ', size: 40, name: 'J', 130 | }, 131 | { 132 | code: 'KeyK', size: 40, name: 'K', 133 | }, 134 | { 135 | code: 'KeyL', size: 40, name: 'L', 136 | }, 137 | { 138 | code: 'Semicolon', size: 40, name: ';', 139 | }, 140 | { 141 | code: 'Quote', size: 40, name: '\'', 142 | }, 143 | { 144 | code: 'Enter', size: 71.5, name: 'Enter', 145 | }, 146 | { 147 | code: 'Numpad1', size: 40, name: '1', 148 | }, 149 | { 150 | code: 'Numpad2', size: 40, name: '2', 151 | }, 152 | { 153 | code: 'Numpad3', size: 40, name: '3', 154 | }, 155 | ] 156 | 157 | export const keys_4 = [{ 158 | code: 'ShiftLeft', size: 102, name: 'Shift', 159 | }, 160 | { 161 | code: 'KeyZ', size: 40, name: 'Z', 162 | }, 163 | { 164 | code: 'KeyX', size: 40, name: 'X', 165 | }, 166 | { 167 | code: 'KeyC', size: 40, name: 'C', 168 | }, 169 | { 170 | code: 'KeyV', size: 40, name: 'V', 171 | }, 172 | { 173 | code: 'KeyB', size: 40, name: 'B', 174 | }, 175 | { 176 | code: 'KeyN', size: 40, name: 'N', 177 | }, 178 | { 179 | code: 'KeyM', size: 40, name: 'M', 180 | }, 181 | { 182 | code: 'Comma', size: 40, name: ',', 183 | }, 184 | { 185 | code: 'Period', size: 40, name: '.', 186 | }, 187 | { 188 | code: 'Slash', size: 40, name: '/', 189 | }, 190 | { 191 | code: 'ShiftRight', size: 102, name: 'Shift', 192 | }, 193 | { 194 | code: 'Numpad0', size: 40, name: '0', 195 | }, 196 | { 197 | code: 'ArrowUp', size: 40, name: '↑', 198 | }, 199 | { 200 | code: 'NumpadDecimal', size: 40, name: '.', 201 | }, 202 | ] 203 | 204 | export const keys_5 = [{ 205 | code: 'ControlLeft', size: 67, name: 'Ctrl', 206 | }, 207 | { 208 | code: 'MetaLeft', size: 67, name: 'Meta', 209 | }, 210 | { 211 | code: 'AltLeft', size: 67, name: 'Alt', 212 | }, 213 | { 214 | code: 'Space', size: 212, name: 'Space', 215 | }, 216 | { 217 | code: 'AltRight', size: 67, name: 'Alt', 218 | }, 219 | { 220 | code: 'MetaRight', size: 67, name: 'Meta', 221 | }, 222 | { 223 | code: 'ControlRight', size: 67, name: 'Ctrl', 224 | }, 225 | { 226 | code: 'ArrowLeft', size: 40, name: '←', 227 | }, 228 | { 229 | code: 'ArrowDown', size: 40, name: '↓', 230 | }, 231 | { 232 | code: 'ArrowRight', size: 40, name: '↑', 233 | }, 234 | ] 235 | 236 | // 游戏按键映射名 237 | export const keyboardNameMaps: Record = { 238 | 'p1UP': '↑', 239 | 'p1DOWN': '↓', 240 | 'p1LEFT': '←', 241 | 'p1RIGHT': '→', 242 | 'p1A': 'A', 243 | 'p1B': 'B', 244 | 'p1C': '连A', 245 | 'p1D': '连B', 246 | 'p1SELECT': '选择', 247 | 'p1START': '开始', 248 | 'p2UP': '↑', 249 | 'p2DOWN': '↓', 250 | 'p2LEFT': '←', 251 | 'p2RIGHT': '→', 252 | 'p2A': 'A', 253 | 'p2B': 'B', 254 | 'p2C': '连A', 255 | 'p2D': '连B', 256 | 'p0SAVE': '保存', 257 | 'p0LOAD': '读取', 258 | 'p0PAUSE': '暂停', 259 | 'p0RESET': '重启', 260 | 'p0SUSPEND': '静音', 261 | 'p0CUT': '截图', 262 | 'p0FULL': '全屏', 263 | } 264 | -------------------------------------------------------------------------------- /server/dist/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __create = Object.create; 3 | var __defProp = Object.defineProperty; 4 | var __getOwnPropDesc = Object.getOwnPropertyDescriptor; 5 | var __getOwnPropNames = Object.getOwnPropertyNames; 6 | var __getProtoOf = Object.getPrototypeOf; 7 | var __hasOwnProp = Object.prototype.hasOwnProperty; 8 | var __copyProps = (to, from, except, desc) => { 9 | if (from && typeof from === "object" || typeof from === "function") { 10 | for (let key of __getOwnPropNames(from)) 11 | if (!__hasOwnProp.call(to, key) && key !== except) 12 | __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); 13 | } 14 | return to; 15 | }; 16 | var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( 17 | // If the importer is in node compatibility mode or this is not an ESM 18 | // file that has been converted to a CommonJS file using a Babel- 19 | // compatible transform (i.e. "__esModule" has not been set), then set 20 | // "default" to the CommonJS "module.exports" for node compatibility. 21 | isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, 22 | mod 23 | )); 24 | 25 | // src/index.ts 26 | var import_express4 = __toESM(require("express")); 27 | 28 | // src/routers/rom_router.ts 29 | var import_express = require("express"); 30 | 31 | // node_modules/.pnpm/kolorist@1.8.0/node_modules/kolorist/dist/esm/index.mjs 32 | var enabled = true; 33 | var globalVar = typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : typeof global !== "undefined" ? global : {}; 34 | var supportLevel = 0; 35 | if (globalVar.process && globalVar.process.env && globalVar.process.stdout) { 36 | const { FORCE_COLOR, NODE_DISABLE_COLORS, NO_COLOR, TERM, COLORTERM } = globalVar.process.env; 37 | if (NODE_DISABLE_COLORS || NO_COLOR || FORCE_COLOR === "0") { 38 | enabled = false; 39 | } else if (FORCE_COLOR === "1" || FORCE_COLOR === "2" || FORCE_COLOR === "3") { 40 | enabled = true; 41 | } else if (TERM === "dumb") { 42 | enabled = false; 43 | } else if ("CI" in globalVar.process.env && [ 44 | "TRAVIS", 45 | "CIRCLECI", 46 | "APPVEYOR", 47 | "GITLAB_CI", 48 | "GITHUB_ACTIONS", 49 | "BUILDKITE", 50 | "DRONE" 51 | ].some((vendor) => vendor in globalVar.process.env)) { 52 | enabled = true; 53 | } else { 54 | enabled = process.stdout.isTTY; 55 | } 56 | if (enabled) { 57 | if (process.platform === "win32") { 58 | supportLevel = 3; 59 | } else { 60 | if (COLORTERM && (COLORTERM === "truecolor" || COLORTERM === "24bit")) { 61 | supportLevel = 3; 62 | } else if (TERM && (TERM.endsWith("-256color") || TERM.endsWith("256"))) { 63 | supportLevel = 2; 64 | } else { 65 | supportLevel = 1; 66 | } 67 | } 68 | } 69 | } 70 | var options = { 71 | enabled, 72 | supportLevel 73 | }; 74 | function kolorist(start, end, level = 1) { 75 | const open = `\x1B[${start}m`; 76 | const close = `\x1B[${end}m`; 77 | const regex = new RegExp(`\\x1b\\[${end}m`, "g"); 78 | return (str) => { 79 | return options.enabled && options.supportLevel >= level ? open + ("" + str).replace(regex, open) + close : "" + str; 80 | }; 81 | } 82 | var reset = kolorist(0, 0); 83 | var bold = kolorist(1, 22); 84 | var dim = kolorist(2, 22); 85 | var italic = kolorist(3, 23); 86 | var underline = kolorist(4, 24); 87 | var inverse = kolorist(7, 27); 88 | var hidden = kolorist(8, 28); 89 | var strikethrough = kolorist(9, 29); 90 | var black = kolorist(30, 39); 91 | var red = kolorist(31, 39); 92 | var green = kolorist(32, 39); 93 | var yellow = kolorist(33, 39); 94 | var blue = kolorist(34, 39); 95 | var magenta = kolorist(35, 39); 96 | var cyan = kolorist(36, 39); 97 | var white = kolorist(97, 39); 98 | var gray = kolorist(90, 39); 99 | var lightGray = kolorist(37, 39); 100 | var lightRed = kolorist(91, 39); 101 | var lightGreen = kolorist(92, 39); 102 | var lightYellow = kolorist(93, 39); 103 | var lightBlue = kolorist(94, 39); 104 | var lightMagenta = kolorist(95, 39); 105 | var lightCyan = kolorist(96, 39); 106 | var bgBlack = kolorist(40, 49); 107 | var bgRed = kolorist(41, 49); 108 | var bgGreen = kolorist(42, 49); 109 | var bgYellow = kolorist(43, 49); 110 | var bgBlue = kolorist(44, 49); 111 | var bgMagenta = kolorist(45, 49); 112 | var bgCyan = kolorist(46, 49); 113 | var bgWhite = kolorist(107, 49); 114 | var bgGray = kolorist(100, 49); 115 | var bgLightRed = kolorist(101, 49); 116 | var bgLightGreen = kolorist(102, 49); 117 | var bgLightYellow = kolorist(103, 49); 118 | var bgLightBlue = kolorist(104, 49); 119 | var bgLightMagenta = kolorist(105, 49); 120 | var bgLightCyan = kolorist(106, 49); 121 | var bgLightGray = kolorist(47, 49); 122 | 123 | // src/utils/logger.ts 124 | var info = function(str) { 125 | console.log(green(str)); 126 | }; 127 | var error = function(str) { 128 | console.log(red(str)); 129 | process.exit(0); 130 | }; 131 | 132 | // src/utils/response.ts 133 | var import_os = __toESM(require("os")); 134 | function sendEmpty(res, target) { 135 | res.send({ 136 | code: 400, 137 | message: `${target}\u5185\u5BB9\u4E0D\u80FD\u4E3A\u7A7A` 138 | }); 139 | } 140 | async function dispatchResponse(target, res, message, err) { 141 | message = message ?? "\u53D1\u751F\u9519\u8BEF"; 142 | try { 143 | await target(); 144 | } catch (e) { 145 | error(`${e}`); 146 | if (err) { 147 | err(e); 148 | } 149 | res.send({ 150 | code: 500, 151 | msg: message 152 | }); 153 | } 154 | } 155 | function getIpAddress() { 156 | const ifaces = import_os.default.networkInterfaces(); 157 | for (const dev in ifaces) { 158 | const iface = ifaces[dev]; 159 | for (let i = 0; i < iface.length; i++) { 160 | const { family, address, internal } = iface[i]; 161 | if (family === "IPv4" && address !== "127.0.0.1" && !internal) { 162 | return address; 163 | } 164 | } 165 | } 166 | } 167 | 168 | // src/server.config.ts 169 | var import_path = require("path"); 170 | var dbPath = "../db/nes.sqlite3"; 171 | var romPath = "../roms"; 172 | var romDir = "/roms/"; 173 | var imgDir = "/roms/img/"; 174 | var hostIp = getIpAddress(); 175 | var getDataBasePath = () => (0, import_path.join)(__dirname, dbPath); 176 | var getRomPath = () => (0, import_path.join)(__dirname, romPath); 177 | var port = 8848; 178 | var baseURL = `http://localhost:${port}`; 179 | if (process.env.NODE_ENV === "development") { 180 | baseURL = `http://${hostIp}:${port}`; 181 | } 182 | 183 | // src/utils/query.ts 184 | function checkQuery(query) { 185 | if (typeof query === "string") { 186 | return query.trim() !== ""; 187 | } 188 | return query !== void 0 && query !== null; 189 | } 190 | function resolveURL(str) { 191 | return baseURL + str; 192 | } 193 | function resolveRomData(rom) { 194 | return { 195 | id: rom.id, 196 | category: rom.Category.dataValues.type, 197 | url: resolveURL(romDir + rom.url), 198 | cover: resolveURL(imgDir + rom.cover), 199 | image: resolveURL(imgDir + rom.image), 200 | title: rom.title, 201 | language: rom.language, 202 | type: rom.type, 203 | source: rom.source, 204 | comment: rom.comment, 205 | location: rom.location, 206 | size: rom.size, 207 | publisher: rom.publisher 208 | }; 209 | } 210 | 211 | // src/services/roms_service.ts 212 | var import_sequelize4 = require("sequelize"); 213 | 214 | // src/sequelize/models/categorys_model.ts 215 | var import_sequelize2 = require("sequelize"); 216 | 217 | // src/sequelize/index.ts 218 | var import_sequelize = require("sequelize"); 219 | var sequelize = new import_sequelize.Sequelize({ 220 | dialect: "sqlite", 221 | storage: getDataBasePath(), 222 | logging() { 223 | return; 224 | } 225 | }); 226 | var sequelize_default = sequelize; 227 | 228 | // src/sequelize/models/categorys_model.ts 229 | var Categorys = class extends import_sequelize2.Model { 230 | }; 231 | var categorys_model = Categorys.init({ 232 | id: { 233 | type: import_sequelize2.DataTypes.TEXT, 234 | allowNull: false, 235 | primaryKey: true 236 | }, 237 | name: { 238 | type: import_sequelize2.DataTypes.TEXT, 239 | allowNull: false 240 | } 241 | }, { 242 | sequelize: sequelize_default, 243 | tableName: "categorys", 244 | freezeTableName: true, 245 | createdAt: false, 246 | updatedAt: false 247 | }); 248 | categorys_model.sync(); 249 | 250 | // src/sequelize/models/roms_model.ts 251 | var import_sequelize3 = require("sequelize"); 252 | function textField() { 253 | return { 254 | type: import_sequelize3.DataTypes.TEXT, 255 | allowNull: false 256 | }; 257 | } 258 | var Roms = class extends import_sequelize3.Model { 259 | }; 260 | var roms_model = Roms.init( 261 | { 262 | id: { 263 | type: import_sequelize3.DataTypes.INTEGER, 264 | allowNull: false, 265 | primaryKey: true 266 | }, 267 | title: textField(), 268 | cover: textField(), 269 | image: textField(), 270 | language: textField(), 271 | type: textField(), 272 | source: textField(), 273 | comment: textField(), 274 | location: textField(), 275 | size: textField(), 276 | publisher: textField(), 277 | url: textField() 278 | }, 279 | { 280 | sequelize: sequelize_default, 281 | modelName: "roms", 282 | freezeTableName: true, 283 | createdAt: false, 284 | updatedAt: false 285 | } 286 | ); 287 | roms_model.belongsTo(categorys_model, { foreignKey: "type", targetKey: "id" }); 288 | roms_model.sync(); 289 | 290 | // src/services/roms_service.ts 291 | async function getRomlist(cat, keyword, page, limit) { 292 | const where = {}; 293 | if (checkQuery(keyword)) { 294 | where.title = { 295 | [import_sequelize4.Op.like]: `%${keyword}%` 296 | }; 297 | } 298 | if (checkQuery(cat)) { 299 | where.type = cat; 300 | } 301 | const result = await roms_model.findAndCountAll({ 302 | attributes: ["id", "title", "cover", "image", "language", "type", "source", "comment", "location", "size", "publisher", "url"], 303 | include: { 304 | model: categorys_model, 305 | attributes: [["name", "type"]] 306 | }, 307 | offset: (+page - 1) * +limit, 308 | limit: +limit, 309 | where 310 | }); 311 | return { 312 | result: result.rows.map((rom) => { 313 | return resolveRomData(rom); 314 | }), 315 | count: result.count 316 | }; 317 | } 318 | async function getRomById(id) { 319 | const romInfo = await roms_model.findByPk(id); 320 | if (romInfo) { 321 | romInfo.url = resolveURL(romDir + romInfo.url); 322 | romInfo.image = resolveURL(imgDir + romInfo.image); 323 | romInfo.cover = resolveURL(imgDir + romInfo.cover); 324 | } 325 | return romInfo; 326 | } 327 | async function getRandomList(n, cat, ignore) { 328 | const where = {}; 329 | if (checkQuery(cat)) { 330 | where.type = cat; 331 | } 332 | if (checkQuery(ignore)) { 333 | where.id = { [import_sequelize4.Op.ne]: ignore }; 334 | } 335 | const result = await roms_model.findAll({ 336 | attributes: ["id", "title", "cover", "image", "language", "type", "source", "comment", "location", "size", "publisher", "url"], 337 | include: { 338 | model: categorys_model, 339 | attributes: [["name", "type"]] 340 | }, 341 | order: [[(0, import_sequelize4.fn)("RANDOM"), "ASC"]], 342 | offset: 1, 343 | limit: +n, 344 | where 345 | }); 346 | return result.map((rom) => { 347 | return resolveRomData(rom); 348 | }); 349 | } 350 | async function getSuggestions(keyword) { 351 | const result = await roms_model.findAll({ 352 | attributes: ["id", "title", "cover"], 353 | where: { 354 | title: { 355 | [import_sequelize4.Op.like]: `%${keyword}%` 356 | } 357 | } 358 | }); 359 | return result; 360 | } 361 | 362 | // src/routers/rom_router.ts 363 | var roms = (0, import_express.Router)(); 364 | roms.get("/romlist", async (req, res) => { 365 | let { cat, keyword, page, limit } = req.query; 366 | cat ??= ""; 367 | keyword ??= ""; 368 | page ??= "1"; 369 | limit ??= "20"; 370 | await dispatchResponse(async () => { 371 | const list = await getRomlist(cat, keyword, +page, +limit); 372 | res.send({ 373 | code: 200, 374 | result: list.result, 375 | count: list.count 376 | }); 377 | }, res); 378 | }); 379 | roms.get("/random", async (req, res) => { 380 | let { n, cat, ignore } = req.query; 381 | n ??= "8"; 382 | await dispatchResponse(async () => { 383 | const result = await getRandomList(n, cat, ignore); 384 | res.send({ code: 200, result }); 385 | }, res); 386 | }); 387 | roms.get("/rom", async (req, res) => { 388 | const id = req.query.id; 389 | if (!checkQuery(id)) { 390 | sendEmpty(res, "id"); 391 | return; 392 | } 393 | await dispatchResponse(async () => { 394 | const rom = await getRomById(id); 395 | if (rom) { 396 | res.send({ 397 | code: 200, 398 | rom 399 | }); 400 | } else { 401 | res.send({ code: 400 }); 402 | } 403 | }, res); 404 | }); 405 | roms.get("/suggestions", async (req, res) => { 406 | const keyword = req.query.keyword; 407 | if (checkQuery(keyword)) { 408 | await dispatchResponse(async () => { 409 | const result = await getSuggestions(keyword); 410 | if (result.length > 0) { 411 | const suggestions = result.map((game) => { 412 | return { 413 | id: game.id, 414 | value: game.title, 415 | cover: resolveURL(imgDir + game.cover) 416 | }; 417 | }); 418 | res.send({ 419 | code: 200, 420 | suggestions 421 | }); 422 | } else { 423 | res.send({ code: 0 }); 424 | } 425 | }, res); 426 | } else { 427 | sendEmpty(res, "keyword"); 428 | return; 429 | } 430 | }); 431 | var rom_router_default = roms; 432 | 433 | // src/routers/categorys_router.ts 434 | var import_express2 = require("express"); 435 | 436 | // src/services/categorys_service.ts 437 | async function getAllCategorys() { 438 | const result = await categorys_model.findAll(); 439 | return result; 440 | } 441 | 442 | // src/routers/categorys_router.ts 443 | var categorys = (0, import_express2.Router)(); 444 | categorys.get("/categorys", async (_, res) => { 445 | await dispatchResponse(async () => { 446 | const reslult = await getAllCategorys(); 447 | res.send({ 448 | code: 200, 449 | categorys: reslult 450 | }); 451 | }, res); 452 | }); 453 | var categorys_router_default = categorys; 454 | 455 | // src/routers/banner_router.ts 456 | var import_express3 = require("express"); 457 | 458 | // src/sequelize/models/banner_model.ts 459 | var import_sequelize5 = require("sequelize"); 460 | var Banner = class extends import_sequelize5.Model { 461 | }; 462 | var banner_model = Banner.init({ 463 | id: { 464 | type: import_sequelize5.DataTypes.INTEGER, 465 | allowNull: false, 466 | primaryKey: true 467 | }, 468 | title: { 469 | type: import_sequelize5.DataTypes.TEXT, 470 | allowNull: false 471 | } 472 | }, { 473 | sequelize: sequelize_default, 474 | tableName: "banner", 475 | freezeTableName: true, 476 | createdAt: false, 477 | updatedAt: false 478 | }); 479 | banner_model.belongsTo(roms_model, { foreignKey: "id", targetKey: "id" }); 480 | banner_model.sync(); 481 | 482 | // src/services/banner_service.ts 483 | async function getBanner() { 484 | const result = await banner_model.findAll({ 485 | attributes: ["id", "title"], 486 | include: { 487 | model: roms_model, 488 | attributes: ["image"] 489 | } 490 | }); 491 | return result.map(({ rom, title, id }) => { 492 | return { 493 | id, 494 | image: resolveURL(imgDir + rom.image), 495 | title 496 | }; 497 | }); 498 | } 499 | 500 | // src/routers/banner_router.ts 501 | var banner = (0, import_express3.Router)(); 502 | banner.get("/banner", async (_, res) => { 503 | await dispatchResponse(async () => { 504 | const banner2 = await getBanner(); 505 | res.send({ 506 | code: 200, 507 | banner: banner2 508 | }); 509 | }, res); 510 | }); 511 | var banner_router_default = banner; 512 | 513 | // src/index.ts 514 | var setHeaders = function(req, res, next) { 515 | res.setHeader("Access-Control-Allow-Origin", "*"); 516 | res.setHeader("Access-Control-Allow-Headers", "*"); 517 | res.setHeader("Access-Control-Allow-Methods", "*"); 518 | if (req.method === "OPTIONS") { 519 | return res.sendStatus(200); 520 | } 521 | next(); 522 | }; 523 | var app = (0, import_express4.default)(); 524 | app.use(import_express4.default.json()).use(setHeaders).use("/roms", import_express4.default.static(getRomPath())).use(categorys_router_default).use(rom_router_default).use(banner_router_default); 525 | if (process.env.NODE_ENV === "development") { 526 | app.set("host", hostIp); 527 | } 528 | app.listen(port, () => { 529 | info(`server: ${baseURL}`); 530 | }); 531 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /client/src/components/GameEmulator.vue: -------------------------------------------------------------------------------- 1 | 387 | 388 | 811 | 812 | 838 | -------------------------------------------------------------------------------- /server/dist/index.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["../src/index.ts","../src/routers/rom_router.ts","../node_modules/.pnpm/kolorist@1.8.0/node_modules/kolorist/src/index.ts","../src/utils/logger.ts","../src/utils/response.ts","../src/server.config.ts","../src/utils/query.ts","../src/services/roms_service.ts","../src/sequelize/models/categorys_model.ts","../src/sequelize/index.ts","../src/sequelize/models/roms_model.ts","../src/routers/categorys_router.ts","../src/services/categorys_service.ts","../src/routers/banner_router.ts","../src/sequelize/models/banner_model.ts","../src/services/banner_service.ts"],"sourcesContent":["import type { Request, Response, RequestHandler, NextFunction } from 'express'\r\nimport express from 'express'\r\nimport roms from './routers/rom_router'\r\nimport categorys from './routers/categorys_router'\r\nimport { port, getRomPath, hostIp, baseURL } from './server.config'\r\nimport * as logger from './utils/logger'\r\nimport banner from './routers/banner_router'\r\n\r\nconst setHeaders: RequestHandler = function (\r\n req: Request,\r\n res: Response,\r\n next: NextFunction\r\n) {\r\n res.setHeader('Access-Control-Allow-Origin', '*') // 允许跨域\r\n res.setHeader('Access-Control-Allow-Headers', '*') // 允许客户端设置请求头\r\n res.setHeader('Access-Control-Allow-Methods', '*') // 允许客户端的请求方式\r\n if (req.method === 'OPTIONS') {return res.sendStatus(200)} // options请求快速结束\r\n next()\r\n}\r\nconst app = express()\r\n\r\napp.use(express.json())\r\n // 请求头\r\n .use(setHeaders)\r\n // 静态资源\r\n .use('/roms', express.static(getRomPath()))\r\n // 路由\r\n .use(categorys)\r\n .use(roms)\r\n .use(banner)\r\n\r\n// 开发模式下配置本地ip域名\r\nif (process.env.NODE_ENV === 'development') {\r\n app.set('host', hostIp)\r\n}\r\n\r\napp.listen(port, () => {\r\n logger.info(`server: ${baseURL}`)\r\n})\r\n","import { Router as router } from 'express'\r\nimport { dispatchResponse, sendEmpty } from '../utils/response'\r\n\r\nimport { checkQuery, resolveURL } from '../utils/query'\r\nimport { imgDir } from '../server.config'\r\nimport { getRandomList, getRomById, getRomlist, getSuggestions } from '../services/roms_service'\r\n\r\nconst roms = router()\r\n\r\n// 获取游戏ROM列表\r\n// /romlist?cat=xxx&keyword=xxx&page=xxx&limit=xxx\r\nroms.get('/romlist', async (req, res) => {\r\n let { cat, keyword, page, limit } = req.query as Record\r\n cat ??= ''\r\n keyword ??= ''\r\n page ??= '1'\r\n limit ??= '20'\r\n await dispatchResponse(async () => {\r\n const list = await getRomlist(cat, keyword, +page, +limit)\r\n res.send({\r\n code: 200, result: list.result, count: list.count,\r\n })\r\n }, res)\r\n})\r\n\r\n// 随机获取N个游戏\r\n// /random?n=xxx&cat=xxx&ignore=xxx\r\nroms.get('/random', async (req, res) => {\r\n let { n, cat, ignore } = req.query as Record\r\n n ??= '8'\r\n await dispatchResponse(async () => {\r\n const result = await getRandomList(n, cat, ignore)\r\n res.send({ code: 200, result })\r\n }, res)\r\n})\r\n\r\n// 根据id获取单个ROM信息\r\n// /rom?id=xxx\r\nroms.get('/rom', async (req, res) => {\r\n const id = req.query.id as string\r\n if (!checkQuery(id)) {\r\n sendEmpty(res, 'id')\r\n return\r\n }\r\n await dispatchResponse(async () => {\r\n const rom = await getRomById(id)\r\n if (rom) {\r\n res.send({\r\n code: 200,\r\n rom,\r\n })\r\n }\r\n else {\r\n res.send({ code: 400 })\r\n }\r\n }, res)\r\n})\r\n\r\n// 搜索建议\r\n// /suggestions?keyword=xxx\r\nroms.get('/suggestions', async (req, res) => {\r\n const keyword = req.query.keyword as string\r\n if (checkQuery(keyword)) {\r\n await dispatchResponse(async () => {\r\n const result = await getSuggestions(keyword)\r\n if (result.length > 0) {\r\n const suggestions = result.map(game => {\r\n return {\r\n id: game.id,\r\n value: game.title,\r\n cover: resolveURL(imgDir + game.cover),\r\n }\r\n })\r\n res.send({\r\n code: 200,\r\n suggestions,\r\n })\r\n }\r\n else {\r\n res.send({ code: 0 })\r\n }\r\n }, res)\r\n }\r\n else {\r\n sendEmpty(res, 'keyword')\r\n return\r\n }\r\n})\r\n\r\nexport default roms\r\n",null,"import { red, yellow, green } from 'kolorist'\r\n\r\nconst info = function (str: string) {\r\n console.log(green(str))\r\n}\r\n\r\nconst warn = function (str: string) {\r\n console.log(yellow(str))\r\n}\r\n\r\nconst error = function (str: string) {\r\n console.log(red(str))\r\n process.exit(0)\r\n}\r\n\r\nconst nestLine = function () {\r\n console.log()\r\n}\r\n\r\nexport {\r\n info,\r\n warn,\r\n error,\r\n nestLine,\r\n}\r\n","import type { Response } from 'express'\r\nimport * as logger from './logger'\r\nimport os from 'os'\r\n\r\nfunction sendEmpty(res: Response, target: string) {\r\n res.send({\r\n code: 400,\r\n message: `${target}内容不能为空`,\r\n })\r\n}\r\n\r\nasync function dispatchResponse(\r\n target: Function,\r\n res: Response,\r\n message?: string,\r\n err?: (args: any) => any\r\n) {\r\n message = message ?? '发生错误'\r\n try {\r\n await target()\r\n }\r\n catch (e) {\r\n logger.error(`${e}`)\r\n if (err) {\r\n err(e)\r\n }\r\n res.send({\r\n code: 500,\r\n msg: message,\r\n })\r\n }\r\n}\r\n\r\nfunction getIpAddress() {\r\n const ifaces = os.networkInterfaces()\r\n for (const dev in ifaces) {\r\n const iface = ifaces[dev]!\r\n\r\n for (let i = 0; i < iface.length; i++) {\r\n const { family, address, internal } = iface[i]\r\n\r\n if (family === 'IPv4' && address !== '127.0.0.1' && !internal) {\r\n return address\r\n }\r\n }\r\n }\r\n}\r\n\r\nexport { sendEmpty, dispatchResponse, getIpAddress }\r\n","import { join } from 'path'\r\nimport { getIpAddress } from './utils/response'\r\n\r\nconst dbPath = '../db/nes.sqlite3'\r\nconst romPath = '../roms'\r\nconst romDir = '/roms/'\r\nconst imgDir = '/roms/img/'\r\nconst hostIp = getIpAddress()\r\nconst getDataBasePath = () => join(__dirname, dbPath)\r\nconst getRomPath = () => join(__dirname, romPath)\r\n\r\nconst port = 8848\r\nlet baseURL = `http://localhost:${port}`\r\n\r\n// 开发模式下配置主机为局域网ip,方便调试移动端\r\nif (process.env.NODE_ENV === 'development') {\r\n baseURL = `http://${hostIp}:${port}`\r\n}\r\n\r\nexport {\r\n romDir,\r\n imgDir,\r\n getDataBasePath,\r\n getRomPath,\r\n port,\r\n baseURL,\r\n hostIp,\r\n}\r\n","import type { RomsInstance } from '../sequelize/models/roms_model'\r\nimport { baseURL, romDir, imgDir } from '../server.config'\r\n\r\nfunction checkQuery(query: T): query is T & {} {\r\n if (typeof query === 'string') {\r\n return query.trim() !== ''\r\n }\r\n return query !== void 0 && query !== null\r\n}\r\n\r\nfunction resolveURL(str: string) {\r\n return baseURL + str\r\n}\r\n\r\nfunction resolveRomData(rom: R) {\r\n return {\r\n id: rom.id,\r\n category: rom.Category.dataValues.type,\r\n url: resolveURL(romDir + rom.url),\r\n cover: resolveURL(imgDir + rom.cover),\r\n image: resolveURL(imgDir + rom.image),\r\n title: rom.title,\r\n language: rom.language,\r\n type: rom.type,\r\n source: rom.source,\r\n comment: rom.comment,\r\n location: rom.location,\r\n size: rom.size,\r\n publisher: rom.publisher,\r\n }\r\n}\r\n\r\nexport { checkQuery, resolveURL, resolveRomData }\r\n","import { Op, fn } from 'sequelize'\r\nimport { categorys_model } from '../sequelize/models/categorys_model'\r\nimport { roms_model } from '../sequelize/models/roms_model'\r\nimport { checkQuery, resolveRomData, resolveURL } from '../utils/query'\r\nimport { imgDir, romDir } from '../server.config'\r\n\r\nasync function getRomlist(cat: string, keyword: string, page: number, limit: number) {\r\n const where: Record = {}\r\n if (checkQuery(keyword)) {\r\n where.title = {\r\n [Op.like]: `%${keyword}%`,\r\n }\r\n }\r\n if (checkQuery(cat)) {\r\n where.type = cat\r\n }\r\n const result = await roms_model.findAndCountAll({\r\n attributes: ['id', 'title', 'cover', 'image', 'language', 'type', 'source', 'comment', 'location', 'size', 'publisher', 'url'],\r\n include: {\r\n model: categorys_model,\r\n attributes: [['name', 'type']],\r\n },\r\n offset: (+page - 1) * +limit,\r\n limit: +limit,\r\n where,\r\n })\r\n return {\r\n result: result.rows.map(rom => {\r\n return resolveRomData(rom)\r\n }),\r\n count: result.count,\r\n }\r\n}\r\n\r\nasync function getRomById(id: string | number) {\r\n const romInfo = await roms_model.findByPk(id)\r\n if (romInfo) {\r\n romInfo.url = resolveURL(romDir + romInfo.url)\r\n romInfo.image = resolveURL(imgDir + romInfo.image)\r\n romInfo.cover = resolveURL(imgDir + romInfo.cover)\r\n }\r\n return romInfo\r\n}\r\n\r\nasync function getRandomList(n: string | number, cat: string, ignore: string) {\r\n const where: Record = {}\r\n if (checkQuery(cat)) {\r\n where.type = cat\r\n }\r\n if (checkQuery(ignore)) {\r\n where.id = { [Op.ne]: ignore }\r\n }\r\n const result = await roms_model.findAll({\r\n attributes: ['id', 'title', 'cover', 'image', 'language', 'type', 'source', 'comment', 'location', 'size', 'publisher', 'url'],\r\n include: {\r\n model: categorys_model,\r\n attributes: [['name', 'type']],\r\n },\r\n order: [[fn('RANDOM'), 'ASC']],\r\n offset: 1,\r\n limit: +n,\r\n where,\r\n })\r\n return result.map(rom => {\r\n return resolveRomData(rom)\r\n })\r\n}\r\n\r\nasync function getSuggestions(keyword: string) {\r\n const result = await roms_model.findAll({\r\n attributes: ['id', 'title', 'cover'],\r\n where: {\r\n title: {\r\n [Op.like]: `%${keyword}%`,\r\n },\r\n },\r\n })\r\n return result\r\n}\r\n\r\nexport { getRomlist, getRomById, getRandomList, getSuggestions }\r\n","import { DataTypes, Model } from 'sequelize'\r\nimport sequelize from '..'\r\n\r\nclass Categorys extends Model {\r\n declare id: number\r\n declare name: string\r\n}\r\n\r\nconst categorys_model = Categorys.init({\r\n id: {\r\n type: DataTypes.TEXT,\r\n allowNull: false,\r\n primaryKey: true,\r\n },\r\n name: {\r\n type: DataTypes.TEXT,\r\n allowNull: false,\r\n },\r\n}, {\r\n sequelize,\r\n tableName: 'categorys',\r\n freezeTableName: true,\r\n createdAt: false,\r\n updatedAt: false,\r\n})\r\n\r\ncategorys_model.sync()\r\n\r\ntype CategorysInstance = InstanceType\r\n\r\nexport { categorys_model, type CategorysInstance }\r\n","import { Sequelize } from 'sequelize'\r\nimport { getDataBasePath } from '../server.config'\r\n\r\nconst sequelize = new Sequelize({\r\n dialect: 'sqlite',\r\n storage: getDataBasePath(),\r\n logging() {\r\n return\r\n },\r\n})\r\n\r\nexport default sequelize\r\n","import { DataTypes, Model } from 'sequelize'\r\nimport sequelize from '..'\r\nimport { categorys_model } from './categorys_model'\r\n\r\nfunction textField() {\r\n return {\r\n type: DataTypes.TEXT,\r\n allowNull: false,\r\n }\r\n}\r\n\r\nclass Roms extends Model {\r\n declare id: number\r\n declare title: string\r\n declare cover: string\r\n declare image: string\r\n declare language: string\r\n declare type: string\r\n declare source: string\r\n declare comment: string\r\n declare location: string\r\n declare size: string\r\n declare publisher: string\r\n declare url: string\r\n // categorys表\r\n declare Category: {\r\n dataValues: {\r\n type: string\r\n }\r\n }\r\n}\r\n\r\nconst roms_model = Roms.init({\r\n id: {\r\n type: DataTypes.INTEGER,\r\n allowNull: false,\r\n primaryKey: true,\r\n },\r\n title: textField(),\r\n cover: textField(),\r\n image: textField(),\r\n language: textField(),\r\n type: textField(),\r\n source: textField(),\r\n comment: textField(),\r\n location: textField(),\r\n size: textField(),\r\n publisher: textField(),\r\n url: textField(),\r\n},\r\n{\r\n sequelize,\r\n modelName: 'roms',\r\n freezeTableName: true,\r\n createdAt: false,\r\n updatedAt: false,\r\n})\r\n\r\nroms_model.belongsTo(categorys_model, { foreignKey: 'type', targetKey: 'id' })\r\nroms_model.sync()\r\n\r\ntype RomsInstance = InstanceType\r\n\r\nexport { roms_model, type RomsInstance }\r\n","import { Router as router } from 'express'\r\nimport { getAllCategorys } from '../services/categorys_service'\r\nimport { dispatchResponse } from '../utils/response'\r\n\r\nconst categorys = router()\r\n\r\ncategorys.get('/categorys', async (_, res) => {\r\n await dispatchResponse(async () => {\r\n const reslult = await getAllCategorys()\r\n res.send({\r\n code: 200,\r\n categorys: reslult,\r\n })\r\n }, res)\r\n})\r\n\r\nexport default categorys\r\n","import { categorys_model } from '../sequelize/models/categorys_model'\r\n\r\nasync function getAllCategorys() {\r\n const result = await categorys_model.findAll()\r\n return result\r\n}\r\n\r\nexport { getAllCategorys }\r\n","import { Router as rotuer } from 'express'\r\nimport { getBanner } from '../services/banner_service'\r\nimport { dispatchResponse } from '../utils/response'\r\n\r\nconst banner = rotuer()\r\n\r\nbanner.get('/banner', async (_, res) => {\r\n await dispatchResponse(async () => {\r\n const banner = await getBanner()\r\n res.send({\r\n code: 200,\r\n banner,\r\n })\r\n }, res)\r\n})\r\n\r\nexport default banner\r\n","import { DataTypes, Model } from 'sequelize'\r\nimport sequelize from '..'\r\nimport { roms_model } from './roms_model'\r\n\r\nclass Banner extends Model {\r\n declare id: number\r\n declare title: string\r\n // roms表\r\n declare rom: {\r\n image: string\r\n }\r\n}\r\n\r\nconst banner_model = Banner.init({\r\n id: {\r\n type: DataTypes.INTEGER,\r\n allowNull: false,\r\n primaryKey: true,\r\n },\r\n title: {\r\n type: DataTypes.TEXT,\r\n allowNull: false,\r\n },\r\n}, {\r\n sequelize,\r\n tableName: 'banner',\r\n freezeTableName: true,\r\n createdAt: false,\r\n updatedAt: false,\r\n})\r\n\r\nbanner_model.belongsTo(roms_model, { foreignKey: 'id', targetKey: 'id' })\r\n\r\nbanner_model.sync()\r\n\r\ntype BannerInstance = InstanceType\r\n\r\nexport { banner_model, type BannerInstance }\r\n","import { roms_model } from '../sequelize/models/roms_model'\r\nimport { banner_model } from '../sequelize/models/banner_model'\r\nimport { resolveURL } from '../utils/query'\r\nimport { imgDir } from '../server.config'\r\n\r\nasync function getBanner() {\r\n const result = await banner_model.findAll({\r\n attributes: ['id', 'title'],\r\n include: {\r\n model: roms_model,\r\n attributes: ['image'],\r\n },\r\n })\r\n return result.map(({ rom, title, id }) => {\r\n return {\r\n id,\r\n image: resolveURL(imgDir + rom.image),\r\n title: title,\r\n }\r\n })\r\n}\r\n\r\nexport { getBanner }\r\n"],"mappings":"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","names":["import_express","os","import_sequelize","import_sequelize","import_sequelize","router","import_express","router","import_express","import_sequelize","rotuer","banner","express"]} --------------------------------------------------------------------------------