├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── navigator-ci.yml │ └── vote-ci.yml ├── .gitignore ├── .prettierrc.js ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── doc ├── DEVELOPEDOC.md ├── OUTLINE-Questionnaire.md ├── QUESTIONNAIREROADMAP.png ├── ROADMAP.md ├── VOTEDETAIL.md ├── VoileLabs-人气投票项目-需求文档-投票结果页面.docx ├── VoileLabs-人气投票项目-需求文档.docx └── 结果页面 - 参数说明.md ├── package.json ├── packages ├── navigator │ ├── .eslintrc.js │ ├── auto-imports.d.ts │ ├── components.d.ts │ ├── index.html │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ └── vercel.json │ ├── shims.d.ts │ ├── src │ │ ├── App.vue │ │ ├── assets │ │ │ ├── logoVoilelabs.png │ │ │ └── title.svg │ │ ├── main.ts │ │ ├── pages │ │ │ ├── [...all].vue │ │ │ └── index.vue │ │ └── styles │ │ │ └── global.postcss │ ├── tsconfig.json │ ├── unocss.config.ts │ └── vite.config.ts ├── result-codegen │ ├── .gitignore │ ├── gen │ │ └── charaCountByYear.json │ ├── package.json │ └── src │ │ ├── db.ts │ │ └── main.ts ├── result │ ├── .eslintrc.js │ ├── auto-imports.d.ts │ ├── components.d.ts │ ├── index.html │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ └── vercel.json │ ├── shims.d.ts │ ├── src │ │ ├── App.vue │ │ ├── assets │ │ │ ├── defaultCharacterImage.png │ │ │ ├── logoVoilelabs.png │ │ │ ├── title.svg │ │ │ └── titleNum.svg │ │ ├── components │ │ │ ├── AdvancedSearch.vue │ │ │ ├── Graph1.vue │ │ │ ├── GraphEvolution.vue │ │ │ ├── GraphMap.vue │ │ │ ├── GraphPie.vue │ │ │ ├── GraphRadar.vue │ │ │ ├── GraphSunburst.vue │ │ │ ├── Nav.vue │ │ │ ├── NavTop.vue │ │ │ ├── Questionnaire.vue │ │ │ └── VoteSelect.vue │ │ ├── composables │ │ │ ├── china.ts │ │ │ ├── graphql │ │ │ │ ├── codegen.yml │ │ │ │ ├── index.ts │ │ │ │ ├── scripts │ │ │ │ │ └── codegenPluginTypePolicy.js │ │ │ │ └── typePolicies.ts │ │ │ └── style.ts │ │ ├── layouts │ │ │ ├── blank.vue │ │ │ └── default.vue │ │ ├── lib │ │ │ ├── Graph.ts │ │ │ ├── Questionnaire.ts │ │ │ ├── decodeAdditionalConstraint.ts │ │ │ ├── getIDtoName.ts │ │ │ ├── graphBar.ts │ │ │ ├── numberFormat.ts │ │ │ └── voteYear.ts │ │ ├── main.ts │ │ ├── pages │ │ │ ├── CharacterReason.vue │ │ │ ├── Couple.vue │ │ │ ├── CoupleDetail.vue │ │ │ ├── CoupleReason.vue │ │ │ ├── CoupleSingleDetail.vue │ │ │ ├── Doujin.vue │ │ │ ├── Music.vue │ │ │ ├── MusicCompare.vue │ │ │ ├── MusicConnect.vue │ │ │ ├── MusicDetail.vue │ │ │ ├── MusicEvolution.vue │ │ │ ├── MusicReason.vue │ │ │ ├── MusicSingleDetail.vue │ │ │ ├── QuestionnaireDetail.vue │ │ │ ├── QuestionnaireInputDetail.vue │ │ │ ├── Test.vue │ │ │ ├── [...all].vue │ │ │ ├── character.vue │ │ │ ├── characterCompare.vue │ │ │ ├── characterConnect.vue │ │ │ ├── characterDetail.vue │ │ │ ├── characterEvolution.vue │ │ │ ├── characterSingleDetail.vue │ │ │ └── index.vue │ │ └── styles │ │ │ └── global.postcss │ ├── tsconfig.json │ ├── unocss.config.ts │ └── vite.config.ts ├── shared │ ├── composables │ │ └── setSiteTitle.ts │ ├── data │ │ ├── character.ts │ │ ├── music.ts │ │ ├── questionnaire.ts │ │ ├── time.ts │ │ ├── voteYear.ts │ │ └── work.ts │ └── package.json └── vote │ ├── .eslintrc.js │ ├── .postcssrc.js │ ├── apollo.config.cjs │ ├── index.html │ ├── package.json │ ├── public │ ├── favicon.ico │ └── vercel.json │ ├── scripts │ └── codegen.js │ ├── src │ ├── common │ │ ├── TestPage.vue │ │ ├── assets │ │ │ ├── Vite App_files │ │ │ │ └── DefaultAvatar.jpg │ │ │ ├── login.svg │ │ │ ├── loginIcon.svg │ │ │ ├── logoOldSystem.ico │ │ │ ├── logoPatchyVideo.png │ │ │ ├── logoVoilelabs.png │ │ │ ├── title.svg │ │ │ └── titleNum.svg │ │ ├── components │ │ │ ├── AutoComplete.vue │ │ │ ├── BackToHome.vue │ │ │ ├── Copyright.vue │ │ │ ├── GlobalMessages.vue │ │ │ ├── Mask.vue │ │ │ ├── NavVote.vue │ │ │ ├── VoteCheckBox.vue │ │ │ ├── VoteMessageBox.vue │ │ │ └── VoteSelect.vue │ │ └── lib │ │ │ ├── getNickName.ts │ │ │ ├── pinin.ts │ │ │ ├── popMessage.ts │ │ │ ├── setSiteTitle.ts │ │ │ └── voteYear.ts │ ├── darkmode │ │ └── index.ts │ ├── dts │ │ ├── components.d.ts │ │ ├── shims-vue.d.ts │ │ └── vite-env.d.ts │ ├── end-page │ │ ├── EndPage.vue │ │ └── lib │ │ │ └── voteEnded.ts │ ├── graphql │ │ ├── codegen.yml │ │ ├── index.ts │ │ ├── scripts │ │ │ └── codegenPluginTypePolicy.js │ │ └── typePolicies.ts │ ├── home │ │ ├── Home.vue │ │ ├── HomeEntry.vue │ │ ├── UserHome.vue │ │ ├── UserSettings.vue │ │ ├── assets │ │ │ └── DefaultAvatar.jpg │ │ ├── components │ │ │ ├── CompleteTag.vue │ │ │ ├── LoginBox.vue │ │ │ ├── UserQuestionnaire.vue │ │ │ ├── UserQuestionnaireDp.vue │ │ │ ├── UserVote.vue │ │ │ └── UserVoteDp.vue │ │ └── lib │ │ │ ├── questionnaireNameById.ts │ │ │ └── user.ts │ ├── main │ │ ├── components │ │ │ └── AppRouterView.vue │ │ └── main.ts │ ├── questionnaire │ │ ├── Questionnaire.vue │ │ ├── components │ │ │ └── QuestionnaireChange.vue │ │ └── lib │ │ │ ├── questionnaire.ts │ │ │ └── questionnaireData.ts │ ├── start-page │ │ ├── StartPage.vue │ │ └── lib │ │ │ └── voteStart.ts │ ├── tailwindcss │ │ ├── global.postcss │ │ └── index.ts │ ├── vote-character │ │ ├── VoteCharacter.vue │ │ ├── assets │ │ │ └── defaultCharacterImage.png │ │ ├── components │ │ │ ├── AdvancedFilter.vue │ │ │ ├── CharacterCard.vue │ │ │ ├── CharacterHonmeiCard.vue │ │ │ └── CharacterSelect.vue │ │ └── lib │ │ │ ├── character.ts │ │ │ ├── characterList.ts │ │ │ ├── voteData.ts │ │ │ └── workList.ts │ ├── vote-couple │ │ ├── VoteCouple.vue │ │ ├── components │ │ │ ├── AdvancedFilter.vue │ │ │ ├── CharacterSelect.vue │ │ │ └── CoupleCard.vue │ │ └── lib │ │ │ ├── couple.ts │ │ │ ├── coupleList.ts │ │ │ ├── voteData.ts │ │ │ └── workList.ts │ ├── vote-doujin │ │ ├── VoteDoujin.vue │ │ ├── components │ │ │ ├── DoujinCard.vue │ │ │ ├── DoujinCardDp.vue │ │ │ ├── EditDoujin.vue │ │ │ └── VoteDoujinDp.vue │ │ └── lib │ │ │ ├── doujin.ts │ │ │ ├── doujinList.ts │ │ │ └── voteData.ts │ └── vote-music │ │ ├── VoteMusic.vue │ │ ├── assets │ │ └── defaultMusicImage.jpg │ │ ├── components │ │ ├── AdvancedFilter.vue │ │ ├── MusicCard.vue │ │ ├── MusicHonmeiCard.vue │ │ └── MusicSelect.vue │ │ └── lib │ │ ├── albumList.ts │ │ ├── music.ts │ │ ├── musicList.ts │ │ └── voteData.ts │ ├── tsconfig.json │ ├── vite.config.ts │ └── windi.config.js ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | public -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * ESLint Configuration File 5 | * 6 | * Docs: https://eslint.org/docs/user-guide/configuring 7 | * @type {import('eslint').Linter.Config} 8 | */ 9 | module.exports = { 10 | root: true, 11 | extends: [ 12 | 'eslint:recommended', 13 | 'plugin:import/recommended', 14 | 'plugin:@typescript-eslint/recommended', 15 | 'plugin:prettier/recommended', 16 | ], 17 | rules: { 18 | 'import/named': 'off', 19 | 'import/no-unresolved': 'off', 20 | '@typescript-eslint/no-unused-vars': 'off', 21 | '@typescript-eslint/no-non-null-assertion': 'off', 22 | '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports', disallowTypeAnnotations: false }], 23 | 'sort-imports': [ 24 | 'error', 25 | { 26 | ignoreCase: false, 27 | ignoreDeclarationSort: true, 28 | ignoreMemberSort: false, 29 | memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'], 30 | allowSeparatedGroups: false, 31 | }, 32 | ], 33 | }, 34 | overrides: [ 35 | { 36 | files: ['**/*.config.js', '**/*rc.js', '**/scripts/**/*.js', '**/public/**/*.js'], 37 | env: { 38 | node: true, 39 | }, 40 | rules: { 41 | '@typescript-eslint/no-var-requires': 'off', 42 | }, 43 | }, 44 | ], 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/navigator-ci.yml: -------------------------------------------------------------------------------- 1 | name: Navigator CI 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: [dev] 6 | paths: 7 | - '.github/workflows/navigator-ci.yml' 8 | - 'pnpm-lock.yaml' 9 | - 'packages/navigator/**' 10 | 11 | jobs: 12 | build: 13 | name: Build and Deploy to Vercel 14 | runs-on: ubuntu-22.04 15 | if: github.repository == 'PatchyVideo/Touhou-Vote' 16 | environment: Navigator-CI 17 | steps: 18 | - uses: actions/checkout@v3 19 | with: 20 | fetch-depth: 0 21 | 22 | - uses: actions/setup-node@v3 23 | with: 24 | node-version: 20 25 | check-latest: true 26 | 27 | - name: Cache pnpm modules 28 | uses: actions/cache@v3 29 | with: 30 | path: ~/.pnpm-store 31 | key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} 32 | restore-keys: | 33 | ${{ runner.os }}-pnpm- 34 | 35 | - uses: pnpm/action-setup@v4 36 | with: 37 | version: 9.3.0 38 | run_install: | 39 | args: [--frozen-lockfile, --strict-peer-dependencies] 40 | timeout-minutes: 10 41 | 42 | - name: Build 43 | run: pnpm build 44 | working-directory: ./packages/navigator 45 | timeout-minutes: 15 46 | 47 | - name: Deploy to Vercel 48 | uses: amondnet/vercel-action@v20 49 | with: 50 | vercel-token: ${{ secrets.VERCEL_TOKEN }} 51 | vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} 52 | vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} 53 | vercel-args: '--prod' 54 | working-directory: ./packages/navigator/dist 55 | github-comment: false 56 | timeout-minutes: 5 57 | -------------------------------------------------------------------------------- /.github/workflows/vote-ci.yml: -------------------------------------------------------------------------------- 1 | name: Vote CI 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: [dev] 6 | paths: 7 | - '.github/workflows/vote-ci.yml' 8 | - 'pnpm-lock.yaml' 9 | - 'packages/result/**' 10 | 11 | jobs: 12 | build: 13 | name: Build and Deploy to Vercel 14 | runs-on: ubuntu-20.04 15 | if: github.repository == 'PatchyVideo/Touhou-Vote' 16 | environment: Vote-CI 17 | steps: 18 | - uses: actions/checkout@v2 19 | with: 20 | fetch-depth: 0 21 | 22 | - uses: actions/setup-node@v2 23 | with: 24 | node-version: 20 25 | check-latest: true 26 | 27 | - name: Cache pnpm modules 28 | uses: actions/cache@v2 29 | with: 30 | path: ~/.pnpm-store 31 | key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} 32 | restore-keys: | 33 | ${{ runner.os }}-pnpm- 34 | 35 | - uses: pnpm/action-setup@v4 36 | with: 37 | version: 9.3.0 38 | run_install: | 39 | args: [--frozen-lockfile, --strict-peer-dependencies] 40 | timeout-minutes: 10 41 | 42 | - name: Codegen 43 | run: pnpm codegen 44 | working-directory: ./packages/result 45 | timeout-minutes: 5 46 | 47 | - name: Build 48 | run: pnpm build 49 | working-directory: ./packages/result 50 | timeout-minutes: 15 51 | 52 | - name: Deploy to Vercel 53 | uses: amondnet/vercel-action@v20 54 | with: 55 | vercel-token: ${{ secrets.VERCEL_TOKEN }} 56 | vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} 57 | vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} 58 | vercel-args: '--prod' 59 | working-directory: ./packages/result/dist 60 | github-comment: false 61 | timeout-minutes: 5 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #General 2 | .DS_Store 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # Snowpack dependency directory (https://snowpack.dev/) 48 | web_modules/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Microbundle cache 60 | .rpt2_cache/ 61 | .rts2_cache_cjs/ 62 | .rts2_cache_es/ 63 | .rts2_cache_umd/ 64 | 65 | # Optional REPL history 66 | .node_repl_history 67 | 68 | # Output of 'npm pack' 69 | *.tgz 70 | 71 | # Yarn Integrity file 72 | .yarn-integrity 73 | 74 | # dotenv environment variables file 75 | .env 76 | .env.test 77 | 78 | # parcel-bundler cache (https://parceljs.org/) 79 | .cache 80 | .parcel-cache 81 | 82 | # Next.js build output 83 | .next 84 | out 85 | 86 | # Nuxt.js build / generate output 87 | .nuxt 88 | dist 89 | 90 | # Gatsby files 91 | .cache/ 92 | # Comment in the public line in if your project uses Gatsby and not Next.js 93 | # https://nextjs.org/blog/next-9-1#public-directory-support 94 | # public 95 | 96 | # vuepress build output 97 | .vuepress/dist 98 | 99 | # Serverless directories 100 | .serverless/ 101 | 102 | # FuseBox cache 103 | .fusebox/ 104 | 105 | # DynamoDB Local files 106 | .dynamodb/ 107 | 108 | # TernJS port file 109 | .tern-port 110 | 111 | # Stores VSCode versions used for testing VSCode extensions 112 | .vscode-test 113 | 114 | # yarn v2 115 | .yarn/cache 116 | .yarn/unplugged 117 | .yarn/build-state.yml 118 | .yarn/install-state.gz 119 | .pnp.* 120 | 121 | # custom 122 | __generated__ 123 | .idea 124 | .vercel 125 | .vs 126 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * Prettier Configuration File 5 | * 6 | * Docs: https://prettier.io/docs/en/options.html 7 | * @type {import('prettier').Options} 8 | */ 9 | module.exports = { 10 | semi: false, 11 | arrowParens: 'always', 12 | singleQuote: true, 13 | endOfLine: 'auto', 14 | printWidth: 120, 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "volar.tsPlugin": false, 3 | "windicss.enableCodeFolding": false, 4 | "typescript.tsdk": "./node_modules/typescript/lib", 5 | "conventionalCommits.scopes": ["vote", "nav", "result", "resgen"], 6 | "files.associations": { 7 | "*.css": "postcss" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

中文东方人气投票 第十一回

2 | 3 |

4 | Commit activity 5 | Version 6 | Lines of code 7 | License 8 |

9 | 10 | 由 [THBWiki](https://thwiki.cc) 与 [VoileLabs](https://github.com/PatchyVideo) 共同开发的 “中文东方人气投票 第十一回” 前端部分。 11 | 12 | ## 网址 13 | 14 | [touhou.vote](https://touhou.vote) 15 | 16 | ## 新功能 17 | 18 | 比起旧的系统,本届多出了以下功能: 19 | 20 | - 全新设计的 UI,支持全响应式布局,更好的兼容手机端 21 | - 精简的登陆逻辑,支持邮箱/手机号登陆和旧版邮箱密码登陆 22 | - 深度拆分的问卷,问卷变短,针对性变强的同时,也将问卷数据实时存入本地,防止意外退出/刷新界面的时候还得重新填写问卷 23 | - 图形化的投票界面,更多丰富的图形效果,规避了文字过多导致的杂乱,看起来更加简洁直观 24 | - 更广泛的搜索系统,即使不知道名字也可以通过出场作品和外号来搜索到自己喜欢的角色/曲目,甚至支持拼音搜索(如“mls”可以搜索到魔理沙) 25 | 26 | ## 开发资料 27 | 28 | 需求文档: 29 | 30 | 需求文档(投票结果页面): 31 | 32 | 路线图: 33 | 34 | 调查问卷大纲: 35 | 36 | 范围一览: 37 | 38 | ## 如何在本地启动项目 39 | 40 | ```bash 41 | # 拉取项目 42 | $ git clone https://github.com/PatchyVideo/Touhou-Vote.git 43 | $ cd Touhou-Vote 44 | 45 | # 安装依赖 46 | $ pnpm i 47 | ``` 48 | 49 | ### 投票页 50 | 51 | ```bash 52 | # 生成 GraphQL Schema 53 | $ pnpm vote:codegen 54 | 55 | # 启动本地调试 56 | $ pnpm vote:dev 57 | 58 | # 构建 & 运行 59 | $ pnpm vote:build 60 | $ pnpm vote:serve 61 | ``` 62 | 63 | ### 导航页 64 | 65 | ```bash 66 | # 启动本地调试 67 | $ pnpm nav:dev 68 | 69 | # 构建 & 运行 70 | $ pnpm nav:build 71 | $ pnpm nav:serve 72 | ``` 73 | 74 | ### 结果页 75 | 76 | ```bash 77 | # 生成 GraphQL Schema 78 | $ pnpm result:codegen 79 | 80 | # 启动本地调试 81 | $ pnpm result:dev 82 | 83 | # 构建 & 运行 84 | $ pnpm result:build 85 | $ pnpm result:serve 86 | ``` 87 | -------------------------------------------------------------------------------- /doc/DEVELOPEDOC.md: -------------------------------------------------------------------------------- 1 | # 分辨率 2 | 3 | 以实际分辨率(原始分辨率*缩放)计算 4 | 5 | 350px(iPhone5)以上的界面都可以正常显示 6 | 7 | # 网站配色 8 | 9 | ## 基础底色 10 | 11 | #3A3531 12 | 13 | ## 基础文字颜色 14 | 15 | #CEBEAD 16 | 17 | ## 基础主色 18 | 19 | *100~900颜色逐渐加深* 20 | 21 | 100: #D6CEE6 22 | 23 | 200: #D6C2F7 24 | 25 | 300: #BDA6EF(强调文字颜色) 26 | 27 | 400: #AD8EEF 28 | 29 | 500: #9C75EF 30 | 31 | 600: #7351C5(主色) 32 | 33 | 700: #5A41A4 34 | 35 | 800: #4A317B 36 | 37 | 900: #312052 38 | 39 | ## 作品提名各个种类颜色 40 | 41 | 音乐:#4CAF50 42 | 43 | 视频:#FF9800 44 | 45 | 绘画&设计:#D6C231 46 | 47 | 游戏&软件:#F77194 48 | 49 | 文字创作:#0075C5 50 | 51 | 手工&服饰:#6339B5 52 | 53 | 其他:#733542 54 | 55 | ## 其他颜色 56 | 57 | 主页分割线:#5a5a56 -------------------------------------------------------------------------------- /doc/QUESTIONNAIREROADMAP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatchyVideo/Touhou-Vote/0f78e2fbc99480bc1fb6a8cc9416cb4ad01fa5fe/doc/QUESTIONNAIREROADMAP.png -------------------------------------------------------------------------------- /doc/ROADMAP.md: -------------------------------------------------------------------------------- 1 | # **第十回 中文东方人气投票** 2 | 3 | ## 目标 4 | 5 | - 界面尽量的**简洁明了** 6 | 1. 多级菜单分类 7 | 2. 表单多页分类 8 | - **方便使用** 9 | 1. 投票流程简化 10 | 2. 尽量减少投票所占用的时间 11 | - **移动端**兼容 12 | 1. 响应式布局 13 | - **拓展性** 14 | 1. 与其他网站的联动 15 | 2. ~~以后的整活兼容~~ 16 | 17 | ## 组成人员 18 | 19 | - 前端 20 | - 后端及数据库 21 | - 服务器及网络运营 22 | - 编辑 23 | - UI、美工 24 | - 画师 25 | 26 | ## 开发框架 27 | 28 | 前端:Vite & UnoCSS 29 | 30 | ## 日常工作 31 | 32 | - 全响应式界面效果(尤其是对手机端不同分辨率的兼容) 33 | - 加载速度优化 34 | - 文本内容校验,**特别是对于存在争议的名字尽量想办法避开争议** 35 | 36 | ## 页面需求 37 | 38 | 参考需求文档 39 | -------------------------------------------------------------------------------- /doc/VOTEDETAIL.md: -------------------------------------------------------------------------------- 1 | # 第十回东方计划人气投票 范围一览 2 | 3 | **参考网站:https://thwik.cc** 4 | 5 | ## 作品 6 | 7 | 截至 2023/12/22 0:00:00 8 | 9 | - 分类:按照时间顺序进行分类,以作品的正式版第一次发售的时间为准 10 | - 入围标准: 11 | - 《东方灵异传》 12 | - 《东方刚欲异闻》 13 | - 以“ZUNSoft”/“上海爱丽丝幻乐团”的名义发布的所有《东方 Project》相关的 STG 作品 14 | - 以“上海爱丽丝幻乐团”和“黄昏边境”的名义发布的所有《东方 Project》相关的 FTG 作品 15 | - 以“zun”/“上海爱丽丝幻乐团”的名义出版的所有《东方 Project》相关的设定集、小说、漫画 16 | 17 | ## 角色 18 | 19 | 截至 2024/12/22 0:00:00 20 | 21 | - 分类:按照作品进行分类,同一角色在不同作品出现的时候以第一次出现的作品为准 22 | 23 | - 入围标准: 24 | 25 | - STG 作品中任意难度下所有拥有符卡,立绘,主题曲中至少一个的人物(如东风谷早苗) 26 | - STG 作品中不符合上述条件,但拥有较多二创作品的道中 boss 级人物(如小恶魔) 27 | - STG 作品中不符合上述条件,但拥有通用称呼的一般敌人(如毛玉) 28 | - STG 作品中不符合上述条件,但设定文档有提及的人物(如圣命莲) 29 | - FTG 作品中任意难度下所有拥有符卡,立绘,主题曲中至少一个的人物(如比那名居天子) 30 | - FTG 作品中不符合上述条件,但设定文档或剧情出场的人物(如非想天则) 31 | - 官方出版物(设定集,漫画,小说)中出场的,有明确的名字、形象的角色(如本居小铃) 32 | - 官方出版物(设定集,漫画,小说)中不符合上述条件,但在剧情/设定中有一定描写且拥有通用称呼的人物(如酷似周杰伦的参拜客) 33 | - 官方 CD (包括附带故事)中出场的,有明确名字、形象的角色(如宇佐见莲子) 34 | - 官方 CD (包括附带故事)中不符合上述条件,但拥有形象或通用称呼的人物(如酒吧“Old Adam”的老板) 35 | 36 | **P.S.** 1.STG 或 FTG 中同时出场的人物每个人物单独进行投票(如九十九姐妹分为“九十九弁弁”、“九十九八桥”两个角色) 37 | 38 | 2.角色“冴月麟“收录至《东方红魔乡》分类下 39 | 40 | 3.角色”博丽灵梦“,“雾雨魔理沙”单独作为一类划分(主角) 41 | 42 | - 命名标准: 43 | 44 | - 以 THBWiki 给出的简体中文/日文为准 45 | - 出现多译名的情况以主流译名为准,存在争议的译名,则在括号内展示其他译名,如“因幡天为(因幡帝)“ 46 | - 无一设命名的角色以通用称呼为准 47 | - 重名人物合并(如风见幽香) 48 | - **TODO**:姐妹关系的人物注明姐妹关系(如露娜萨 普莉兹姆利巴(大姐)、梅露兰 普莉兹姆利巴(二姐)、莉莉卡 普莉兹姆利巴(妹妹)) 49 | 50 | # 音乐 51 | 52 | 截至 2024/12/22 0:00:00 53 | 54 | - 分类:按照专辑进行分类,曲目发布日期以所在专辑正式版最早发布为准 55 | 56 | - 入围标准: 57 | 58 | - 作品收录范围中,STG 作品中出现的曲目(不包括音效)(如”信仰是为了虚幻之人“) 59 | - 作品收录范围中,FTG 作品的 OST(如”东方绯想天“) 60 | - 官方出版物(设定集,漫画,小说)中附带 CD 中的曲目(如”识文解意的爱书人“) 61 | - 以”ZUN“的个人名义发布的,被广泛认为和东方相关的曲目(如”童祭 ~ Innocent Treasures“) 62 | 63 | **P.S.** 1.同一曲名收录在不同专辑的时候视为相同的曲目(如“東方星蓮船 〜 Undefined Fantastic Object.”收录的”感情的摩天楼 ~ Cosmic Mind“和”鸟船遗迹~Trojan Green Asteroid.“收录的”感情的摩天楼 ~ Cosmic Mind“视为相同曲目) 64 | 65 | 2.没有专辑的曲目,其所属专辑视为“幻想的音乐”,曲目发布日期以第一次可考的、正式出现的时间为准 66 | 67 | # 提名 68 | 69 | 2021/1/1 0:00:00 ~ 2024/1/1 0:00:00 70 | 71 | - 分类: 72 | 73 | - 音乐 74 | - 视频 75 | - 绘画&设计 76 | - 游戏&软件 77 | - 文学创作 78 | - 手工制品 79 | - 其他周边 80 | 81 | - 入围标准: 82 | 83 | - 东方相关的同人作品 84 | 85 | **P.S.** 1.以发布日期计算作品日期 86 | 87 | 2.同一个作品在不同网站上发布视为同一作品,但不包括营销号盗用的作品 88 | 89 | 3.不包括可能出现版权问题或争议话题的 IP 联合创作作品(如创价 x 东方) 90 | 91 | 4.不包括 R18 作品 92 | 93 | 5.单品和组合作品(如游戏的 OST 和游戏本身)视为不同的作品。 94 | 95 | **\*投票范围的最终解释权归 [THBWiki](https://thwik.cc) 所有** 96 | -------------------------------------------------------------------------------- /doc/VoileLabs-人气投票项目-需求文档-投票结果页面.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatchyVideo/Touhou-Vote/0f78e2fbc99480bc1fb6a8cc9416cb4ad01fa5fe/doc/VoileLabs-人气投票项目-需求文档-投票结果页面.docx -------------------------------------------------------------------------------- /doc/VoileLabs-人气投票项目-需求文档.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatchyVideo/Touhou-Vote/0f78e2fbc99480bc1fb6a8cc9416cb4ad01fa5fe/doc/VoileLabs-人气投票项目-需求文档.docx -------------------------------------------------------------------------------- /doc/结果页面 - 参数说明.md: -------------------------------------------------------------------------------- 1 | # 结果页面 - 参数说明 2 | 3 | 本文档对人气投票结果页面中出现的参数给出具体的计算公式,在统计社群行为时给大家提供参考 4 | 5 | ## 角色/音乐/CP部门的元信息 6 | 7 | 以下参数如不在括号内标注部门,则默认所有部门都包含该参数。 8 | 9 | ⚠️需要注意的是,使用“高级搜索”中的“从投票中筛选”对结果进行筛选时,下述参数都会变为筛选后的数量 10 | 11 | ⚠️需要注意的是,CP部门会将投票数量为1的CP纳入统计,但结果页面并不展示投票数量为1的CP 12 | 13 | - **角色数**(角色部门):投票范围内的角色的数量 14 | - **曲目数**(音乐部门):投票范围内的曲目数量 15 | - **参投CP数**(CP部门):CP部门的所有有效票中,CP的种类(包含角色相同的CP视为同一个CP) 16 | - **有效票数**:对应部门下,成功提交的、符合投票规范的票(下称“**有效票**”)的数量 17 | - ⚠️需要注意的是,本届人气投票的“票数”概念是“每个账号每个部门对应一张票”,每一张票有若干个普通票位和一个本命票位 18 | - **本命票数**:对应部门下,成功提交的、符合投票规范的票中,填写了“本命票位”的票的数量 19 | - **平均得票数**:平均每位角色/曲目/CP所得到的票数(角色数/曲目数/参投CP数 ➗ 有效票数) 20 | - **中位得票数**:从小到大进行排序,中间位次的角色/曲目/CP的票数(如果中间位次有两个则取二者平均值) 21 | 22 | ## 角色/音乐/CP部门的“本届投票结果页面” 23 | 24 | 以下参数如不在括号内标注部门,则默认所有部门都包含该参数。 25 | 26 | ⚠️需要注意的是,使用“高级搜索”中的“从投票中筛选”对结果进行筛选时,下述参数都以筛选后的结果计算 27 | 28 | - **名次**:取决于当前排序的标准,默认排序标准为“票数”,具体规则如下: 29 | - 同一个票数为同一名次 30 | - 同一名次的先后顺序以“本命票数”为先后顺序,如果“本命票数”同样相同,则以角色/曲目/角色A的系统ID(与默认出场时间顺序上相同)为先后顺序 31 | - 当有并列名次时,占据虚位,下一个名次要顺位递推。如:“灵乌路空”和“埴安神袿姬”的票数相同,则在默认排序标准下二者皆为第39名。而下一位“天弓千亦”则会因此排到第41位 32 | - **角色名**(角色部门):投票范围内的角色的中文名,译名以THBWiki给出的译名为准,出现争议译名的情况下将其他译名以括号的形式标注,如“因幡天为(因幡帝)” 33 | - **曲目名**(音乐部门):投票范围内的曲目的中文名,译名以THBWiki给出的译名为准 34 | - **角色名A**(CP部门):所投CP的第一位角色的中文名,标准参考“角色名” 35 | - 需要注意的是,CP包含的角色并没有顺序的区分 36 | - **角色名B**(CP部门):所投CP的第二位角色的中文名,标准参考“角色名” 37 | - **角色名C**(CP部门):所投CP的第三位角色的中文名,标准参考“角色名”,如果该CP不含第三位角色,则该列标记为“ - ” 38 | - **票数**:投票位(普通票位&本命票位)中,含有该角色/音乐/CP的有效票的数量 39 | - **A主动率**(CP部门):投票位(普通票位&本命票位)含有该CP的有效票中,设定角色A为“主动”的有效票占比 40 | - **B主动率**(CP部门):投票位(普通票位&本命票位)含有该CP的有效票中,设定角色B为“主动”的有效票占比 41 | - **C主动率**(CP部门):投票位(普通票位&本命票位)含有该CP的有效票中,设定角色C为“主动”的有效票占比,如果该CP不含第三位角色,则该列标记为“ - ” 42 | - **无主动率**(CP部门):投票位(普通票位&本命票位)含有该CP的有效票中,未设定“主动”的有效票占比 43 | - “A主动率”、“B主动率”、“C主动率”、“无主动率”之和为100%(C主动率如为” - “则视为0,精确位数的关系可能会有微小偏差) 44 | - **本命数**:本命票位为该角色/音乐/CP的有效票的数量 45 | - **本命率**:本命票位为该角色/音乐/CP的有效票在所有投票位(普通票位&本命票位)中含有该角色/音乐/CP的有效票中所占的比例(本命数 ➗ 票数) 46 | - **本命加权**:将本命票位的权重由1调整为2(普通票位权重不变仍为1),所计算出的该角色加权后的票数(1 * 普通票数 + 2 * 本命票数,即票数 + 本命数) 47 | - **票数占比**:所有有效票中,投票位(普通票位&本命票位)中含有该角色/音乐/CP的有效票占比(票数 ➗ 有效票数) 48 | - 使用“高级搜索”中的“从投票中筛选”对结果进行筛选时,该参数名称改为“同投率” 49 | - **本命占比**:本命票位为该角色/音乐/CP的有效票在所有的本命票中的占比(本命数 ➗ 本命票数) 50 | - **男性数**:调查问卷里的第一题“您的性别是?(填写的信息仅用于数据分析,不会以任何形式披露投票者个人信息)”选择选项“男”,且投票位(普通票位&本命票位)含有该角色/音乐/CP的有效票的数量 51 | - **男性比例**:投票位(普通票位&本命票位)中含有该角色/音乐/CP的有效票中,调查问卷的第一题“您的性别是?(填写的信息仅用于数据分析,不会以任何形式披露投票者个人信息)”选择选项“男”的有效票占比 52 | - **占总体男性比例**:调查问卷的第一题“您的性别是?(填写的信息仅用于数据分析,不会以任何形式披露投票者个人信息)”选择选项“男”的有效票中,投票位(普通票位&本命票位)中含有该角色/音乐/CP的有效票占比 53 | - **女性数**:调查问卷里的第一题“您的性别是?(填写的信息仅用于数据分析,不会以任何形式披露投票者个人信息)”选择选项“女”,且投票位(普通票位&本命票位)含有该角色/音乐/CP的有效票的数量 54 | - **女性比例**:投票位(普通票位&本命票位)中含有该角色/音乐/CP的有效票中,调查问卷的第一题“您的性别是?(填写的信息仅用于数据分析,不会以任何形式披露投票者个人信息)”选择选项“女”的有效票占比 55 | - “男性比例“与”女性比例“之和为100%(精确位数的关系可能会有微小偏差) 56 | - **占总体女性比例**:调查问卷的第一题“您的性别是?(填写的信息仅用于数据分析,不会以任何形式披露投票者个人信息)”选择选项“女”的有效票中,投票位(普通票位&本命票位)中含有该角色/音乐/CP的有效票占比 57 | - **日文名**(角色部门、音乐部门):投票范围内的角色的日文名,以THBWiki给出的日文名为准 58 | - **所在专辑**(音乐部门):该曲目第一次登场时所在的专辑的中文名,以THBWiki给出的译名为准(存在特殊情况不满足此条件,最终解释权归THBWiki所有) 59 | - **所属作品类型**(角色部门):角色所属作品的类型 60 | - **所属作品**(角色部门):角色第一次登场的作品的中文名,以THBWiki给出的译名为准 61 | - **初登场时间**(角色部门、音乐部门):角色/曲目第一次登场的时间,格式为“年(四位数字)/月(两位数字)/日(两位数字)” -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "touhou-vote", 3 | "version": "2.1.0", 4 | "private": true, 5 | "packageManager": "pnpm@9.3.0", 6 | "scripts": { 7 | "vote:codegen": "nr -C packages/vote codegen", 8 | "vote:dev": "nr -C packages/vote dev", 9 | "vote:build": "nr -C packages/vote build", 10 | "vote:serve": "nr -C packages/vote serve", 11 | "nav:dev": "nr -C packages/navigator dev", 12 | "nav:build": "nr -C packages/navigator build", 13 | "nav:serve": "nr -C packages/navigator serve", 14 | "result:codegen": "nr -C packages/result codegen", 15 | "result:dev": "nr -C packages/result dev", 16 | "result:build": "nr -C packages/result build", 17 | "result:serve": "nr -C packages/result serve", 18 | "lint": "eslint --fix ." 19 | }, 20 | "devDependencies": { 21 | "@antfu/ni": "^0.21.12", 22 | "@types/eslint": "^8.37.0", 23 | "@types/eslint-plugin-prettier": "^3.1.0", 24 | "@types/node": "^18.15.13", 25 | "@types/prettier": "^2.7.2", 26 | "@typescript-eslint/eslint-plugin": "^5.59.0", 27 | "@typescript-eslint/parser": "^5.59.0", 28 | "eslint": "^8.38.0", 29 | "eslint-config-prettier": "^8.8.0", 30 | "eslint-plugin-import": "^2.27.5", 31 | "eslint-plugin-prettier": "^4.2.1", 32 | "eslint-plugin-vue": "^9.11.0", 33 | "prettier": "^2.8.7", 34 | "tsx": "^3.12.6", 35 | "typescript": "^5.0.4" 36 | } 37 | } -------------------------------------------------------------------------------- /packages/navigator/.eslintrc.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * ESLint Configuration File 5 | * 6 | * Docs: https://eslint.org/docs/user-guide/configuring 7 | * @type {import('eslint').Linter.Config} 8 | */ 9 | module.exports = { 10 | rules: { 11 | 'no-undef': 'off', 12 | }, 13 | overrides: [ 14 | { 15 | files: ['*.js', '*.jsx'], 16 | env: { 17 | es2020: true, 18 | browser: true, 19 | }, 20 | }, 21 | { 22 | files: ['*.ts', '*.tsx'], 23 | env: { 24 | es2020: true, 25 | browser: true, 26 | }, 27 | parser: '@typescript-eslint/parser', 28 | }, 29 | { 30 | files: ['*.vue'], 31 | env: { 32 | es2020: true, 33 | browser: true, 34 | }, 35 | parser: 'vue-eslint-parser', 36 | parserOptions: { 37 | parser: '@typescript-eslint/parser', 38 | }, 39 | globals: { 40 | defineProps: 'readonly', 41 | defineEmits: 'readonly', 42 | defineExpose: 'readonly', 43 | withDefaults: 'readonly', 44 | }, 45 | }, 46 | ], 47 | } 48 | -------------------------------------------------------------------------------- /packages/navigator/components.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* prettier-ignore */ 3 | // @ts-nocheck 4 | // Generated by unplugin-vue-components 5 | // Read more: https://github.com/vuejs/core/pull/3399 6 | import '@vue/runtime-core' 7 | 8 | export {} 9 | 10 | declare module '@vue/runtime-core' { 11 | export interface GlobalComponents { 12 | RouterLink: typeof import('vue-router')['RouterLink'] 13 | RouterView: typeof import('vue-router')['RouterView'] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/navigator/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 中文东方人气投票 导航页 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/navigator/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@touhou-vote/navigator", 3 | "private": true, 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "vite build", 7 | "serve": "vite preview" 8 | }, 9 | "dependencies": { 10 | "@vueuse/core": "^10.0.2", 11 | "date-fns": "^2.29.3", 12 | "vue": "^3.2.47", 13 | "vue-router": "^4.1.6" 14 | }, 15 | "devDependencies": { 16 | "@iconify-json/carbon": "^1.1.16", 17 | "@unocss/reset": "^0.51.5", 18 | "@vitejs/plugin-vue": "^4.1.0", 19 | "@vue/compiler-sfc": "^3.2.47", 20 | "unocss": "^0.57.7", 21 | "unplugin-auto-import": "^0.15.3", 22 | "unplugin-vue-components": "^0.24.1", 23 | "vite": "^4.5.3", 24 | "vite-plugin-pages": "^0.29.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/navigator/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatchyVideo/Touhou-Vote/0f78e2fbc99480bc1fb6a8cc9416cb4ad01fa5fe/packages/navigator/public/favicon.ico -------------------------------------------------------------------------------- /packages/navigator/public/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "public": false, 4 | "github": { 5 | "enabled": false 6 | }, 7 | "rewrites": [ 8 | { 9 | "source": "/(.*)", 10 | "destination": "/index.html" 11 | } 12 | ], 13 | "headers": [ 14 | { 15 | "source": "/manifest.webmanifest", 16 | "headers": [ 17 | { 18 | "key": "content-type", 19 | "value": "application/manifest+json" 20 | } 21 | ] 22 | } 23 | ] 24 | } -------------------------------------------------------------------------------- /packages/navigator/shims.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable @typescript-eslint/ban-types */ 3 | 4 | declare module '*.vue' { 5 | import type { DefineComponent } from 'vue' 6 | const component: DefineComponent<{}, {}, any> 7 | export default component 8 | } 9 | -------------------------------------------------------------------------------- /packages/navigator/src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /packages/navigator/src/assets/logoVoilelabs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatchyVideo/Touhou-Vote/0f78e2fbc99480bc1fb6a8cc9416cb4ad01fa5fe/packages/navigator/src/assets/logoVoilelabs.png -------------------------------------------------------------------------------- /packages/navigator/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import { createRouter, createWebHistory } from 'vue-router' 3 | import routes from 'virtual:generated-pages' 4 | import App from './App.vue' 5 | 6 | import '@unocss/reset/tailwind.css' 7 | import './styles/global.postcss' 8 | import 'uno.css' 9 | 10 | const app = createApp(App) 11 | const router = createRouter({ 12 | history: createWebHistory('/nav/'), 13 | routes, 14 | }) 15 | app.use(router) 16 | app.mount('#app') 17 | -------------------------------------------------------------------------------- /packages/navigator/src/pages/[...all].vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /packages/navigator/src/styles/global.postcss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.loli.net/css?family=Quicksand'); 2 | 3 | .quicksand { 4 | font-family: 'Quicksand'; 5 | } 6 | -------------------------------------------------------------------------------- /packages/navigator/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "exclude": ["dist", "node_modules"], 4 | "compilerOptions": { 5 | "lib": ["ESNext", "ESNext.AsyncIterable", "DOM", "WebWorker"], 6 | "types": ["vite/client", "vite-plugin-pages/client"], 7 | "jsx": "preserve", 8 | "baseUrl": ".", 9 | "paths": { 10 | "@/*": ["./src/*"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/navigator/unocss.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, presetIcons, presetUno, transformerDirectives } from 'unocss' 2 | 3 | export default defineConfig({ 4 | presets: [ 5 | presetUno(), 6 | presetIcons({ 7 | scale: 1.2, 8 | warn: true, 9 | }), 10 | ], 11 | transformers: [transformerDirectives()], 12 | }) 13 | -------------------------------------------------------------------------------- /packages/navigator/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { defineConfig } from 'vite' 3 | import vue from '@vitejs/plugin-vue' 4 | import pages from 'vite-plugin-pages' 5 | import components from 'unplugin-vue-components/vite' 6 | import autoImport from 'unplugin-auto-import/vite' 7 | import unocss from 'unocss/vite' 8 | 9 | export default defineConfig({ 10 | resolve: { 11 | alias: { 12 | '@/': `${path.resolve(__dirname, 'src')}/`, 13 | }, 14 | }, 15 | plugins: [ 16 | vue(), 17 | // https://github.com/hannoeru/vite-plugin-pages 18 | pages(), 19 | // https://github.com/antfu/unplugin-auto-import 20 | autoImport({ 21 | imports: ['vue', 'vue-router', '@vueuse/core'], 22 | dts: true, 23 | }), 24 | // https://github.com/antfu/vite-plugin-components 25 | components({ 26 | dts: true, 27 | }), 28 | // https://github.com/antfu/unocss 29 | // see unocss.config.ts for config 30 | unocss(), 31 | ], 32 | build: { 33 | sourcemap: true, 34 | assetsDir: 'nav/assets', 35 | }, 36 | esbuild: { 37 | charset: 'utf8', 38 | }, 39 | }) 40 | -------------------------------------------------------------------------------- /packages/result-codegen/.gitignore: -------------------------------------------------------------------------------- 1 | data/ 2 | -------------------------------------------------------------------------------- /packages/result-codegen/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@touhou-vote/result-codegen", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "gen": "tsx src/main.ts" 7 | }, 8 | "devDependencies": { 9 | "@types/rimraf": "^4.0.5", 10 | "node-gyp": "^9.3.1" 11 | }, 12 | "dependencies": { 13 | "@touhou-vote/vote": "workspace:*", 14 | "@vscode/sqlite3": "5.1.4-vscode", 15 | "bson": "^5.2.0", 16 | "rimraf": "^5.0.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/result-codegen/src/db.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from 'fs/promises' 2 | import type { Statement } from '@vscode/sqlite3' 3 | import sqlite3_ from '@vscode/sqlite3' 4 | import { promisify } from 'util' 5 | 6 | const { Database } = sqlite3_.verbose() 7 | 8 | export async function importDB(file: string) { 9 | console.log(`> Import DB ${file} > Importing`) 10 | const db = new Database(':memory:') 11 | 12 | const run = promisify(db.run.bind(db)) 13 | const exec = promisify(db.exec.bind(db)) 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | const each = (sql: string, cb: (this: Statement, err: Error | null, row: any) => void) => 16 | new Promise((resolve, reject) => { 17 | db.each(sql, cb, (err, count) => { 18 | if (err) reject(err) 19 | else resolve(count) 20 | }) 21 | }) 22 | 23 | db.serialize() 24 | 25 | await exec(await readFile(new URL(`../data/${file}`, import.meta.url), 'utf8')) 26 | 27 | console.log(`> Import DB ${file} > Done`) 28 | 29 | return { 30 | db, 31 | run, 32 | exec, 33 | each, 34 | } 35 | } 36 | 37 | export type ImportedDB = Awaited> 38 | -------------------------------------------------------------------------------- /packages/result/.eslintrc.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * ESLint Configuration File 5 | * 6 | * Docs: https://eslint.org/docs/user-guide/configuring 7 | * @type {import('eslint').Linter.Config} 8 | */ 9 | module.exports = { 10 | rules: { 11 | 'no-undef': 'off', 12 | }, 13 | overrides: [ 14 | { 15 | files: ['*.js', '*.jsx'], 16 | env: { 17 | es2020: true, 18 | browser: true, 19 | }, 20 | }, 21 | { 22 | files: ['*.ts', '*.tsx'], 23 | env: { 24 | es2020: true, 25 | browser: true, 26 | }, 27 | parser: '@typescript-eslint/parser', 28 | }, 29 | { 30 | files: ['*.vue'], 31 | env: { 32 | es2020: true, 33 | browser: true, 34 | }, 35 | parser: 'vue-eslint-parser', 36 | parserOptions: { 37 | parser: '@typescript-eslint/parser', 38 | }, 39 | globals: { 40 | defineProps: 'readonly', 41 | defineEmits: 'readonly', 42 | defineExpose: 'readonly', 43 | withDefaults: 'readonly', 44 | }, 45 | }, 46 | ], 47 | } 48 | -------------------------------------------------------------------------------- /packages/result/components.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* prettier-ignore */ 3 | // @ts-nocheck 4 | // Generated by unplugin-vue-components 5 | // Read more: https://github.com/vuejs/core/pull/3399 6 | import '@vue/runtime-core' 7 | 8 | export {} 9 | 10 | declare module '@vue/runtime-core' { 11 | export interface GlobalComponents { 12 | AdvancedSearch: typeof import('./src/components/AdvancedSearch.vue')['default'] 13 | Graph1: typeof import('./src/components/Graph1.vue')['default'] 14 | GraphEvolution: typeof import('./src/components/GraphEvolution.vue')['default'] 15 | GraphMap: typeof import('./src/components/GraphMap.vue')['default'] 16 | GraphPie: typeof import('./src/components/GraphPie.vue')['default'] 17 | GraphRadar: typeof import('./src/components/GraphRadar.vue')['default'] 18 | GraphSunburst: typeof import('./src/components/GraphSunburst.vue')['default'] 19 | Nav: typeof import('./src/components/Nav.vue')['default'] 20 | NavTop: typeof import('./src/components/NavTop.vue')['default'] 21 | Questionnaire: typeof import('./src/components/Questionnaire.vue')['default'] 22 | RouterLink: typeof import('vue-router')['RouterLink'] 23 | RouterView: typeof import('vue-router')['RouterView'] 24 | VoteSelect: typeof import('./src/components/VoteSelect.vue')['default'] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/result/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 投票结果 - 第11回 中文东方人气投票 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/result/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@touhou-vote/result", 3 | "private": true, 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "vite build", 7 | "serve": "vite preview", 8 | "codegen": "graphql-codegen --config ./src/composables/graphql/codegen.yml" 9 | }, 10 | "dependencies": { 11 | "@apollo/client": "^3.7.12", 12 | "@touhou-vote/result-codegen": "workspace:*", 13 | "@touhou-vote/shared": "workspace:*", 14 | "@vue/apollo-composable": "4.0.0-beta.4", 15 | "@vue/apollo-util": "4.0.0-beta.4", 16 | "@vueuse/core": "^10.0.2", 17 | "bson-objectid": "^2.0.4", 18 | "d3": "^7.8.4", 19 | "date-fns": "^2.29.3", 20 | "echarts": "^5.4.2", 21 | "graphql": "^16.8.1", 22 | "nprogress": "1.0.0-1", 23 | "vue": "^3.2.47", 24 | "vue-router": "^4.1.6" 25 | }, 26 | "devDependencies": { 27 | "@graphql-codegen/cli": "^3.3.0", 28 | "@graphql-codegen/fragment-matcher": "^4.0.1", 29 | "@graphql-codegen/schema-ast": "^3.0.1", 30 | "@graphql-codegen/typescript": "^3.0.3", 31 | "@iconify-json/carbon": "^1.1.16", 32 | "@types/d3": "^7.4.0", 33 | "@types/echarts": "^4.9.17", 34 | "@types/lz-string": "^1.5.0", 35 | "@types/nprogress": "^0.2.0", 36 | "@unocss/reset": "^0.51.5", 37 | "@vitejs/plugin-vue": "^4.1.0", 38 | "@vue/compiler-sfc": "^3.2.47", 39 | "lz-string": "^1.5.0", 40 | "ts-poet": "^6.4.1", 41 | "unocss": "^0.57.7", 42 | "unplugin-auto-import": "^0.15.3", 43 | "unplugin-vue-components": "^0.24.1", 44 | "vite": "^4.5.3", 45 | "vite-plugin-pages": "^0.29.0", 46 | "vite-plugin-vue-layouts": "^0.8.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/result/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatchyVideo/Touhou-Vote/0f78e2fbc99480bc1fb6a8cc9416cb4ad01fa5fe/packages/result/public/favicon.ico -------------------------------------------------------------------------------- /packages/result/public/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "public": false, 4 | "github": { 5 | "enabled": false 6 | }, 7 | "rewrites": [ 8 | { 9 | "source": "/(.*)", 10 | "destination": "/index.html" 11 | } 12 | ], 13 | "headers": [ 14 | { 15 | "source": "/manifest.webmanifest", 16 | "headers": [ 17 | { 18 | "key": "content-type", 19 | "value": "application/manifest+json" 20 | } 21 | ] 22 | } 23 | ] 24 | } -------------------------------------------------------------------------------- /packages/result/shims.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable @typescript-eslint/ban-types */ 3 | 4 | declare module '*.vue' { 5 | import type { DefineComponent } from 'vue' 6 | const component: DefineComponent<{}, {}, any> 7 | export default component 8 | } 9 | -------------------------------------------------------------------------------- /packages/result/src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /packages/result/src/assets/defaultCharacterImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatchyVideo/Touhou-Vote/0f78e2fbc99480bc1fb6a8cc9416cb4ad01fa5fe/packages/result/src/assets/defaultCharacterImage.png -------------------------------------------------------------------------------- /packages/result/src/assets/logoVoilelabs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatchyVideo/Touhou-Vote/0f78e2fbc99480bc1fb6a8cc9416cb4ad01fa5fe/packages/result/src/assets/logoVoilelabs.png -------------------------------------------------------------------------------- /packages/result/src/assets/titleNum.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 24 | 29 | 36 | 37 | -------------------------------------------------------------------------------- /packages/result/src/components/GraphEvolution.vue: -------------------------------------------------------------------------------- 1 | 2 | 121 | 122 | -------------------------------------------------------------------------------- /packages/result/src/components/GraphPie.vue: -------------------------------------------------------------------------------- 1 | 12 | 105 | 106 | -------------------------------------------------------------------------------- /packages/result/src/components/GraphSunburst.vue: -------------------------------------------------------------------------------- 1 | 12 | 128 | 129 | -------------------------------------------------------------------------------- /packages/result/src/components/NavTop.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 126 | 127 | -------------------------------------------------------------------------------- /packages/result/src/components/VoteSelect.vue: -------------------------------------------------------------------------------- 1 | 40 | 92 | -------------------------------------------------------------------------------- /packages/result/src/composables/graphql/codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: 3 | - https://touhou.ai/vote-be/graphql 4 | generates: 5 | src/composables/graphql/__generated__/graphql.ts: 6 | plugins: 7 | - typescript 8 | config: 9 | useImplementingTypes: true 10 | addUnderscoreToArgsType: true 11 | nonOptionalTypename: true 12 | scalars: 13 | DateTimeUtc: Date 14 | UtcDateTime: Date 15 | ObjectId: '../index#ObjectID as ObjectId' 16 | src/composables/graphql/__generated__/graphql.fragment.ts: 17 | plugins: 18 | - fragment-matcher 19 | src/composables/graphql/__generated__/schema.graphql: 20 | plugins: 21 | - schema-ast 22 | src/composables/graphql/__generated__/typePolicies.ts: 23 | plugins: 24 | - src/composables/graphql/scripts/codegenPluginTypePolicy.js 25 | config: 26 | scalarTypePolicies: 27 | DateTimeUtc: '../typePolicies#dateTypePolicy' 28 | UtcDateTime: '../typePolicies#dateTypePolicy' 29 | ObjectId: '../typePolicies#objectIdTypePolicy' 30 | -------------------------------------------------------------------------------- /packages/result/src/composables/graphql/scripts/codegenPluginTypePolicy.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // https://github.com/homebound-team/graphql-typescript-scalar-type-policies/blob/ec2ccd99acb9a64302d133b9afabb90ec62d5420/src/index.ts 3 | 4 | const { isNonNullType, isObjectType, isScalarType } = require('graphql') 5 | const { code, imp } = require('ts-poet') 6 | 7 | /** Generates field policies for user-defined types, i.e. Date handling. */ 8 | // @type import('@graphql-codegen/plugin-helpers').PluginFunction 9 | const plugin = async (schema, _, config) => { 10 | const { scalarTypePolicies = {} } = config || {} 11 | 12 | function isScalarWithTypePolicy(f) { 13 | let type = f.type 14 | if (isNonNullType(type)) type = type.ofType 15 | 16 | return isScalarType(type) && scalarTypePolicies[type.name] !== undefined 17 | } 18 | 19 | const content = await code` 20 | export default { 21 | ${Object.values(schema.getTypeMap()) 22 | .filter(isObjectType) 23 | .filter((t) => !t.name.startsWith('__')) 24 | .filter((t) => Object.values(t.getFields()).some(isScalarWithTypePolicy)) 25 | .map((type) => { 26 | return code`${type.name}: { fields: { ${Object.values(type.getFields()) 27 | .filter(isScalarWithTypePolicy) 28 | .map((field) => { 29 | let type = field.type 30 | if (isNonNullType(type)) type = type.ofType 31 | 32 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 33 | // @ts-expect-error 34 | return code`${field.name}: ${toImp(scalarTypePolicies[type.name])},` 35 | })} } },` 36 | })} 37 | }; 38 | `.toString() 39 | return { content } 40 | } 41 | 42 | // Maps the graphql-code-generation convention of `@src/context#Context` to ts-poet's `Context@@src/context`. 43 | function toImp(spec) { 44 | if (!spec) return undefined 45 | 46 | const [path, symbol] = spec.split('#') 47 | return imp(`${symbol}@${path}`) 48 | } 49 | 50 | module.exports = { 51 | plugin, 52 | } 53 | -------------------------------------------------------------------------------- /packages/result/src/composables/graphql/typePolicies.ts: -------------------------------------------------------------------------------- 1 | import type { FieldPolicy } from '@apollo/client/core' 2 | import { ObjectID } from '.' 3 | 4 | export const dateTypePolicy: FieldPolicy = { 5 | merge: (_, incoming) => { 6 | if (incoming === undefined || incoming === null) return incoming 7 | else if (incoming instanceof Date) return incoming 8 | else return new Date(incoming as string) 9 | }, 10 | } 11 | 12 | export const objectIdTypePolicy: FieldPolicy = { 13 | merge: (_, incoming) => { 14 | if (incoming === undefined || incoming === null) return incoming 15 | else if (incoming instanceof ObjectID) return incoming 16 | else return new ObjectID(incoming as string) 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /packages/result/src/composables/style.ts: -------------------------------------------------------------------------------- 1 | import type { Ref } from 'vue' 2 | 3 | export const screens = { 4 | sm: '576px', 5 | md: '720px', 6 | lg: '992px', 7 | xl: '1200px', 8 | '2xl': '1400px', 9 | '3xl': '1540px', 10 | '4xl': '1860px', 11 | '5xl': '2480px', 12 | } 13 | 14 | const queries: [string, Ref][] = Object.entries(screens).map(([k, v]) => [ 15 | k, 16 | useMediaQuery(`(min-width: ${v})`), 17 | ]) 18 | 19 | export const screenSizes = reactive({ 20 | ...Object.fromEntries(queries), 21 | ...Object.fromEntries(queries.map(([k, v]) => [`<${k}`, computed(() => !v.value)])), 22 | ...Object.fromEntries( 23 | queries.map(([k, v], i) => [`@${k}`, computed(() => v.value && !(queries[i + 1] ?? [])[1].value)]) 24 | ), 25 | }) 26 | -------------------------------------------------------------------------------- /packages/result/src/layouts/blank.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /packages/result/src/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /packages/result/src/lib/Graph.ts: -------------------------------------------------------------------------------- 1 | import { deadline, startTime, timeFormat } from '@touhou-vote/shared/data/time' 2 | import type { VotingTrendItem } from '@/composables/graphql/__generated__/graphql' 3 | const hourOfMs = 60 * 60 * 1000 4 | export const GraphTimeRange = (() => { 5 | const range: string[] = [] 6 | for (let timer = startTime + hourOfMs; timer <= deadline; timer = timer + hourOfMs) { 7 | range.push(timeFormat(new Date(timer))) 8 | } 9 | return range 10 | })() 11 | 12 | export interface GraphDataLine { 13 | name: string 14 | data: number[] 15 | } 16 | export function getAddedTrendData(name: string, trend: VotingTrendItem[]): GraphDataLine { 17 | const trendData: GraphDataLine = { 18 | name: name, 19 | data: [], 20 | } 21 | for (let i = 0; i < GraphTimeRange.length; i++) trendData.data.push(0) 22 | for (let i = 0; i < trend.length; i++) { 23 | trendData.data[trend[i].hrs] = trend[i].cnt 24 | } 25 | return trendData 26 | } 27 | export function getTrendData(name: string, trend: VotingTrendItem[]): GraphDataLine { 28 | const trendData: GraphDataLine = getAddedTrendData(name, trend) 29 | for (let i = 1; i < trendData.data.length; i++) { 30 | trendData.data[i] = trendData.data[i - 1] + trendData.data[i] 31 | } 32 | return trendData 33 | } 34 | 35 | export interface GraphDataPie { 36 | name: string 37 | value: number 38 | } 39 | export interface GraphDataSunburst { 40 | id: string 41 | name: string 42 | value: number 43 | } 44 | export interface GraphDataRadar { 45 | name: string 46 | value: number[] 47 | } 48 | export interface GraphDataMap { 49 | name: string 50 | value: number 51 | } 52 | -------------------------------------------------------------------------------- /packages/result/src/lib/Questionnaire.ts: -------------------------------------------------------------------------------- 1 | import { questionnaire } from '@touhou-vote/shared/data/questionnaire' 2 | 3 | export const allQuestionnaireIDList = computed(() => { 4 | const IDList: string[] = [] 5 | for (const bigQuestionnaire in questionnaire) { 6 | for (const smallQuestionnaire in questionnaire[bigQuestionnaire]) { 7 | for (const questionLibrary of questionnaire[bigQuestionnaire][smallQuestionnaire].questions) { 8 | for (const question of questionLibrary) { 9 | if (question.id % 10 != 0) IDList.push('q' + question.id) 10 | } 11 | } 12 | } 13 | } 14 | return IDList 15 | }) 16 | 17 | export function qIDToID(qID: string): string { 18 | return qID.substring(1, qID.length) 19 | } 20 | 21 | interface Question { 22 | id: string 23 | question: string 24 | type: 'Single' | 'Multiple' | 'Input' 25 | options: { 26 | id: number 27 | content: string 28 | }[] 29 | } 30 | function IDToBigQuestionnaire(ID: string): string { 31 | const IDNum = Number(ID.substring(0, 1)) 32 | if (IDNum === 1) return 'mainQuestionnaire' 33 | else return 'extraQuestionnaire' 34 | } 35 | function IDToSmallQuestionnaire(ID: string): string { 36 | const IDNum = Number(ID.substring(0, 2)) 37 | switch (IDNum) { 38 | case 11: 39 | return 'requiredQuestionnaire' 40 | case 12: 41 | return 'optionalQuestionnaire1' 42 | case 13: 43 | return 'optionalQuestionnaire2' 44 | case 21: 45 | return 'exQuestionnaire1' 46 | case 22: 47 | return 'exQuestionnaire2' 48 | case 23: 49 | return 'exQuestionnaire3' 50 | case 24: 51 | return 'exQuestionnaire4' 52 | case 25: 53 | return 'exQuestionnaire5' 54 | default: 55 | return '' 56 | } 57 | } 58 | function IDToQuestionLibrary(ID: string): number { 59 | return Number(ID.substring(2, 4)) - 1 60 | } 61 | export function findQuestionWithQuestionID(QuestionID: string): Question { 62 | return { 63 | id: String( 64 | questionnaire[IDToBigQuestionnaire(QuestionID)][IDToSmallQuestionnaire(QuestionID)].questions[ 65 | IDToQuestionLibrary(QuestionID) 66 | ].find((item) => item.id === Number(QuestionID))?.id || '00000' 67 | ), 68 | question: 69 | questionnaire[IDToBigQuestionnaire(QuestionID)][IDToSmallQuestionnaire(QuestionID)].questions[ 70 | IDToQuestionLibrary(QuestionID) 71 | ].find((item) => item.id === Number(QuestionID))?.question || '被遗忘的问题 ~ Error Occured', 72 | type: 73 | questionnaire[IDToBigQuestionnaire(QuestionID)][IDToSmallQuestionnaire(QuestionID)].questions[ 74 | IDToQuestionLibrary(QuestionID) 75 | ].find((item) => item.id === Number(QuestionID))?.type || 'Input', 76 | options: 77 | questionnaire[IDToBigQuestionnaire(QuestionID)][IDToSmallQuestionnaire(QuestionID)].questions[ 78 | IDToQuestionLibrary(QuestionID) 79 | ] 80 | .find((item) => item.id === Number(QuestionID)) 81 | ?.options.map((item) => { 82 | return { 83 | id: item.id, 84 | content: item.content, 85 | } 86 | }) || [], 87 | } 88 | } 89 | 90 | export function getSmallQuestionnaireChineseName(qID: string): string { 91 | const ID = Number(qIDToID(qID)) 92 | for (const bigQuestionnaire in questionnaire) 93 | for (const smallQuestionnaire in questionnaire[bigQuestionnaire]) { 94 | if (questionnaire[bigQuestionnaire][smallQuestionnaire].id === ID) 95 | return questionnaire[bigQuestionnaire][smallQuestionnaire].name 96 | } 97 | return '' 98 | } 99 | -------------------------------------------------------------------------------- /packages/result/src/lib/decodeAdditionalConstraint.ts: -------------------------------------------------------------------------------- 1 | import { decompressFromEncodedURIComponent } from 'lz-string' 2 | 3 | export function getAdditionalConstraintString(additionalConstraintUrl: string): string { 4 | const additionalConstraintObject = JSON.parse(decompressFromEncodedURIComponent(additionalConstraintUrl) || '{}') 5 | if (additionalConstraintUrl === '' || JSON.stringify(additionalConstraintObject) === '{}') return '' 6 | let additionalConstraintString = '' 7 | if (additionalConstraintObject.charactersFirst) 8 | additionalConstraintString += `AND chars_first="${additionalConstraintObject.charactersFirst}" ` 9 | if (additionalConstraintObject.characters.length) { 10 | for (let i = 0; i < additionalConstraintObject.characters.length; i++) 11 | additionalConstraintString += `AND chars:["${additionalConstraintObject.characters[i]}"] ` 12 | } 13 | if (additionalConstraintObject.musicsFirst) 14 | additionalConstraintString += `AND musics_first="${additionalConstraintObject.musicsFirst}" ` 15 | if (additionalConstraintObject.musics.length) { 16 | for (let i = 0; i < additionalConstraintObject.musics.length; i++) 17 | additionalConstraintString += `AND musics:["${additionalConstraintObject.musics[i]}"] ` 18 | } 19 | if (additionalConstraintObject.questionnaire.length) { 20 | for (let i = 0; i < additionalConstraintObject.questionnaire.length; i++) 21 | additionalConstraintString += `AND q${additionalConstraintObject.questionnaire[i].questionID}=${additionalConstraintObject.questionnaire[i].answerID}` 22 | } 23 | return additionalConstraintString.trim().slice(4).trim() 24 | } 25 | -------------------------------------------------------------------------------- /packages/result/src/lib/getIDtoName.ts: -------------------------------------------------------------------------------- 1 | import { characterList } from '@touhou-vote/shared/data/character' 2 | import { musicList } from '@touhou-vote/shared/data/music' 3 | 4 | export function getIDtoCharacterName() { 5 | return characterList.map((item) => { 6 | return { 7 | id: item.id, 8 | name: item.name, 9 | } 10 | }) 11 | } 12 | 13 | export function getIDtoMusicName() { 14 | return musicList.map((item) => { 15 | return { 16 | id: item.id, 17 | name: item.name, 18 | } 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /packages/result/src/lib/graphBar.ts: -------------------------------------------------------------------------------- 1 | import type * as echarts from 'echarts/core' 2 | import type { LegendComponentOption, ToolboxComponentOption, TooltipComponentOption } from 'echarts/components' 3 | type EChartsOption = echarts.ComposeOption 4 | 5 | export function getGraphBarOption( 6 | types: string[], 7 | totalData: number[], 8 | maleData: number[] = [], 9 | femaleData: number[] = [] 10 | ): EChartsOption { 11 | const series = [ 12 | { 13 | type: 'bar', 14 | label: { 15 | show: true, 16 | }, 17 | stack: 'total', 18 | data: totalData, 19 | }, 20 | ] 21 | if (maleData.length && femaleData.length) 22 | series.push( 23 | { 24 | type: 'bar', 25 | label: { 26 | show: true, 27 | }, 28 | stack: 'sex', 29 | data: maleData, 30 | }, 31 | { 32 | type: 'bar', 33 | stack: 'sex', 34 | label: { 35 | show: true, 36 | }, 37 | data: femaleData, 38 | } 39 | ) 40 | return { 41 | legend: {}, 42 | toolbox: { 43 | feature: { 44 | saveAsImage: {}, 45 | }, 46 | }, 47 | tooltip: {}, 48 | xAxis: { 49 | type: 'category', 50 | data: types, 51 | }, 52 | yAxis: { 53 | type: 'value', 54 | }, 55 | grid: { 56 | left: '10', 57 | containLabel: true, 58 | }, 59 | series: series, 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/result/src/lib/numberFormat.ts: -------------------------------------------------------------------------------- 1 | export function toPercentageString(num: number): string { 2 | return (num * 100).toFixed(2) + '%' 3 | } 4 | export function toTimeFormat(time: string): string { 5 | time = time.slice(0, 4) + '/' + time.slice(4) 6 | time = time.slice(0, 7) + '/' + time.slice(7) 7 | return time 8 | } 9 | -------------------------------------------------------------------------------- /packages/result/src/lib/voteYear.ts: -------------------------------------------------------------------------------- 1 | import { voteYear } from '@touhou-vote/shared/data/voteYear' 2 | 3 | export { voteYear } 4 | -------------------------------------------------------------------------------- /packages/result/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import { createRouter, createWebHistory } from 'vue-router' 3 | import NProgress from 'nprogress' 4 | import { setupLayouts } from 'virtual:generated-layouts' 5 | import generatedRoutes from '~pages' 6 | import App from './App.vue' 7 | 8 | import 'nprogress/css/nprogress.css' 9 | import '@unocss/reset/tailwind.css' 10 | import '@/styles/global.postcss' 11 | import 'uno.css' 12 | 13 | NProgress.start() 14 | 15 | // create graphql client 16 | const client = createApollo() 17 | // vue app 18 | const app = createApp( 19 | defineComponent({ 20 | render: () => [h(App)], 21 | setup() { 22 | provideClient(client) 23 | }, 24 | }) 25 | ) 26 | 27 | const routes = setupLayouts(generatedRoutes) 28 | const router = createRouter({ 29 | history: createWebHistory('/v11/'), 30 | strict: true, 31 | routes, 32 | }) 33 | app.use(router) 34 | app.mount('#app') 35 | 36 | NProgress.done() 37 | -------------------------------------------------------------------------------- /packages/result/src/pages/Couple.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 108 | 109 | 110 | meta: 111 | navid: couple 112 | 113 | -------------------------------------------------------------------------------- /packages/result/src/pages/Music.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 123 | 124 | 125 | meta: 126 | navid: music 127 | 128 | -------------------------------------------------------------------------------- /packages/result/src/pages/MusicConnect.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | meta: 9 | navid: music 10 | 11 | -------------------------------------------------------------------------------- /packages/result/src/pages/Test.vue: -------------------------------------------------------------------------------- 1 | 5 | 8 | -------------------------------------------------------------------------------- /packages/result/src/pages/[...all].vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /packages/result/src/pages/character.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 123 | 124 | 125 | meta: 126 | navid: character 127 | 128 | -------------------------------------------------------------------------------- /packages/result/src/pages/characterConnect.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | meta: 9 | navid: character 10 | 11 | -------------------------------------------------------------------------------- /packages/result/src/pages/index.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 93 | 94 | 95 | 96 | 97 | meta: 98 | layout: blank 99 | navid: summary 100 | 101 | -------------------------------------------------------------------------------- /packages/result/src/styles/global.postcss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.loli.net/css?family=Quicksand'); 2 | 3 | a { 4 | @apply cursor-pointer text-accent-600 hover:text-accent-900 transition transition-colors; 5 | } 6 | 7 | /* Scrollbar */ 8 | *::-webkit-scrollbar { 9 | width: 16px; 10 | } 11 | *::-webkit-scrollbar-thumb { 12 | height: 56px; 13 | background-clip: content-box; 14 | background-color: theme('colors.gray.500'); 15 | border: 4px solid transparent; 16 | border-radius: 9999px; 17 | } 18 | *::-webkit-scrollbar-thumb:hover { 19 | background-color: theme('colors.gray.400'); 20 | } 21 | 22 | .quicksand { 23 | font-family: 'Quicksand'; 24 | } 25 | 26 | .input-border, 27 | .linkbox-border { 28 | box-shadow: inset 0 0 0 2px theme('colors.gray.200'); 29 | position: relative; 30 | vertical-align: middle; 31 | @apply rounded-xl; 32 | &::before, 33 | &::after { 34 | content: ''; 35 | position: absolute; 36 | top: 0; 37 | left: 0; 38 | height: 100%; 39 | width: 100%; 40 | transform-origin: center; 41 | transition: transform 5ms; 42 | @apply rounded-xl border-accent-300; 43 | } 44 | &::before { 45 | transform: scale3d(0, 1, 1); 46 | @apply border-t-2 border-b-2; 47 | } 48 | &::after { 49 | transform: scale3d(1, 0, 1); 50 | @apply border-l-2 border-r-2; 51 | } 52 | } 53 | .input-border:focus-within, 54 | .linkbox-border:hover { 55 | &::before, 56 | &::after { 57 | transform: scale3d(1, 1, 1); 58 | } 59 | } 60 | .input-border-md, 61 | .linkbox-border-md { 62 | @apply rounded; 63 | 64 | &::before, 65 | &::after { 66 | @apply rounded; 67 | } 68 | } 69 | 70 | .float-arrow-box { 71 | .float-arrow { 72 | @apply transform transition-transform duration-200 ease-out; 73 | } 74 | } 75 | .float-arrow-box:hover { 76 | .float-arrow { 77 | @apply translate-x-1/4; 78 | } 79 | } 80 | 81 | .ani-link { 82 | position: relative; 83 | &::after { 84 | content: ''; 85 | position: absolute; 86 | top: 100%; 87 | left: 0; 88 | width: 100%; 89 | height: 1px; 90 | z-index: -1; 91 | background-color: theme('colors.accent.600'); 92 | transition: all 200ms cubic-bezier(0.2, 0, 0, 1); 93 | transform-origin: bottom center; 94 | @apply rounded-md; 95 | } 96 | } 97 | .ani-link:hover { 98 | &::after { 99 | top: 0; 100 | height: 100%; 101 | background-color: theme('colors.accent.200'); 102 | } 103 | } 104 | 105 | /* Background */ 106 | .page { 107 | position: fixed; 108 | top: 0; 109 | left: 0; 110 | right: 0; 111 | height: 100vh; 112 | z-index: -1; 113 | overflow: hidden; 114 | } 115 | @media only screen and (max-width: 720px) { 116 | .page { 117 | background-image: radial-gradient(circle at top left, theme('colors.accent.200') min(95vh), #ffffff 0); 118 | } 119 | } 120 | @media only screen and (min-width: 720px) { 121 | .page { 122 | background-image: radial-gradient(circle at 80% 30%, theme('colors.accent.200') min(40%), #ffffff 0); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /packages/result/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "exclude": ["dist", "node_modules"], 4 | "compilerOptions": { 5 | "lib": ["ESNext", "ESNext.AsyncIterable", "DOM", "WebWorker"], 6 | "types": ["vite/client", "vue/ref-macros", "vite-plugin-pages/client", "vite-plugin-vue-layouts/client"], 7 | "jsx": "preserve", 8 | "baseUrl": ".", 9 | "paths": { 10 | "@/*": ["./src/*"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/result/unocss.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, presetIcons, presetUno, transformerDirectives, transformerVariantGroup } from 'unocss' 2 | 3 | export default defineConfig({ 4 | presets: [ 5 | presetUno(), 6 | presetIcons({ 7 | scale: 1.2, 8 | warn: true, 9 | }), 10 | ], 11 | transformers: [transformerVariantGroup(), transformerDirectives()], 12 | theme: { 13 | colors: { 14 | // accent-600 is the default accent color of the project 15 | accent: { 16 | 100: '#D6CEE6', 17 | 200: '#D6C2F7', 18 | 300: '#BDA6EF', 19 | 400: '#AD8EEF', 20 | 500: '#9C75EF', 21 | 600: '#7351C5', 22 | 700: '#5A41A4', 23 | 800: '#4A317B', 24 | 900: '#312052', 25 | }, 26 | subaccent: '#3A3531', 27 | textaccent: '#CEBEAD', 28 | }, 29 | screens: { 30 | '3xl': '1920px', 31 | }, 32 | boxShadow: { 33 | around: '5px 20px 25px rgba(0, 0, 0, 0.1), -3px -3px 10px rgba(0, 0, 0, 0.1)', 34 | }, 35 | }, 36 | }) 37 | -------------------------------------------------------------------------------- /packages/result/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { defineConfig } from 'vite' 3 | import vue from '@vitejs/plugin-vue' 4 | import pages from 'vite-plugin-pages' 5 | import layouts from 'vite-plugin-vue-layouts' 6 | import components from 'unplugin-vue-components/vite' 7 | import autoImport from 'unplugin-auto-import/vite' 8 | import unocss from 'unocss/vite' 9 | 10 | export default defineConfig({ 11 | optimizeDeps: { 12 | include: ['@apollo/client/core', '@apollo/client/utilities'], 13 | exclude: ['@apollo/client', '@touhou-vote/result-codegen', '@touhou-vote/shared'], 14 | }, 15 | resolve: { 16 | alias: { 17 | '@/': `${path.resolve(__dirname, 'src')}/`, 18 | }, 19 | }, 20 | plugins: [ 21 | vue({ 22 | reactivityTransform: true, 23 | }), 24 | // https://github.com/hannoeru/vite-plugin-pages 25 | pages(), 26 | // https://github.com/JohnCampionJr/vite-plugin-vue-layouts 27 | layouts(), 28 | // https://github.com/antfu/unplugin-auto-import 29 | autoImport({ 30 | dirs: ['src/composables/*/index.{js,ts,jsx,tsx}'], 31 | imports: [ 32 | 'vue', 33 | 'vue/macros', 34 | 'vue-router', 35 | '@vueuse/core', 36 | { 37 | '@touhou-vote/shared/composables/setSiteTitle': ['setSiteTitle'], 38 | }, 39 | ], 40 | dts: true, 41 | }), 42 | // https://github.com/antfu/vite-plugin-components 43 | components({ 44 | dirs: ['src/components'], 45 | dts: true, 46 | directoryAsNamespace: true, 47 | }), 48 | // https://github.com/antfu/unocss 49 | // see unocss.config.ts for config 50 | unocss(), 51 | ], 52 | server: { 53 | proxy: { 54 | '/res-be': { 55 | target: 'https://touhou.ai/vote-be', 56 | changeOrigin: true, 57 | secure: false, 58 | rewrite: (path) => path.replace(/^\/res-be/, ''), 59 | }, 60 | }, 61 | }, 62 | build: { 63 | sourcemap: true, 64 | assetsDir: 'v11/assets', 65 | }, 66 | esbuild: { 67 | charset: 'utf8', 68 | }, 69 | }) 70 | -------------------------------------------------------------------------------- /packages/shared/composables/setSiteTitle.ts: -------------------------------------------------------------------------------- 1 | export function setSiteTitle(title = ''): void { 2 | document.title = title + (title && ' - ') + '第11回 中文东方人气投票' 3 | } 4 | -------------------------------------------------------------------------------- /packages/shared/data/time.ts: -------------------------------------------------------------------------------- 1 | // Strat Time: 2023/12/29 18:00:00 UTC+8 2 | // Notice that month start at "0", not "1", so January is "0" 3 | export const startTime = new Date(2023, 11, 29, 18).getTime() 4 | // Deadline: 2024/1/15 00:00:00 UTC+8 5 | // Notice that month start at "0", not "1", so January is "0" 6 | export const deadline = new Date(2024, 0, 15).getTime() 7 | export function timeFormat(date: Date): string { 8 | return date.getFullYear() + '-' + (date.getMonth() + 1) + '-' + date.getDate() + ' ' + date.getHours() + ': 00' 9 | } 10 | export const startTimeString = timeFormat(new Date(startTime)) 11 | export const deadlineString = timeFormat(new Date(deadline)) 12 | -------------------------------------------------------------------------------- /packages/shared/data/voteYear.ts: -------------------------------------------------------------------------------- 1 | export const voteYear = 11 2 | -------------------------------------------------------------------------------- /packages/shared/data/work.ts: -------------------------------------------------------------------------------- 1 | export interface Work { 2 | name: string 3 | kind: 'old' | 'new' | 'CD' | 'book' | 'others' | '' 4 | } 5 | 6 | export const workList: Work[] = [ 7 | { name: '东方灵异传', kind: 'old' }, 8 | { name: '东方封魔录', kind: 'old' }, 9 | { name: '东方梦时空', kind: 'old' }, 10 | { name: '东方幻想乡', kind: 'old' }, 11 | { name: '东方怪绮谈', kind: 'old' }, 12 | { name: '东方红魔乡', kind: 'new' }, 13 | { name: '东方妖妖梦', kind: 'new' }, 14 | { name: '东方萃梦想', kind: 'new' }, 15 | { name: '东方永夜抄', kind: 'new' }, 16 | { name: '东方花映塚', kind: 'new' }, 17 | { name: '东方风神录', kind: 'new' }, 18 | { name: '东方绯想天', kind: 'new' }, 19 | { name: '东方地灵殿', kind: 'new' }, 20 | { name: '东方星莲船', kind: 'new' }, 21 | { name: '东方非想天则', kind: 'new' }, 22 | { name: '东方文花帖DS', kind: 'new' }, 23 | { name: '东方神灵庙', kind: 'new' }, 24 | { name: '东方心绮楼', kind: 'new' }, 25 | { name: '东方辉针城', kind: 'new' }, 26 | { name: '东方深秘录', kind: 'new' }, 27 | { name: '东方绀珠传', kind: 'new' }, 28 | { name: '东方凭依华', kind: 'new' }, 29 | { name: '东方天空璋', kind: 'new' }, 30 | { name: '东方鬼形兽', kind: 'new' }, 31 | { name: '东方刚欲异闻', kind: 'new' }, 32 | { name: '东方虹龙洞', kind: 'new' }, 33 | { name: '东方兽王园', kind: 'new' }, 34 | { name: '蓬莱人形', kind: 'CD' }, 35 | { name: '莲台野夜行', kind: 'CD' }, 36 | { name: '旧约酒馆', kind: 'CD' }, 37 | { name: '东方文花帖(书籍)', kind: 'book' }, 38 | { name: '东方求闻史纪', kind: 'book' }, 39 | { name: '东方三月精', kind: 'book' }, 40 | { name: '东方儚月抄', kind: 'book' }, 41 | { name: '东方香霖堂', kind: 'book' }, 42 | { name: '东方茨歌仙', kind: 'book' }, 43 | { name: '东方铃奈庵', kind: 'book' }, 44 | { name: '东方智灵奇传', kind: 'book' }, 45 | { name: '东方醉蝶华', kind: 'book' }, 46 | { name: '其他', kind: 'others' }, 47 | ] 48 | -------------------------------------------------------------------------------- /packages/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@touhou-vote/shared", 3 | "private": true 4 | } 5 | -------------------------------------------------------------------------------- /packages/vote/.eslintrc.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * ESLint Configuration File 5 | * 6 | * Docs: https://eslint.org/docs/user-guide/configuring 7 | * @type {import('eslint').Linter.Config} 8 | */ 9 | module.exports = { 10 | overrides: [ 11 | { 12 | files: ['*.js', '*.jsx'], 13 | env: { 14 | es2020: true, 15 | browser: true, 16 | }, 17 | }, 18 | { 19 | files: ['*.ts', '*.tsx'], 20 | env: { 21 | es2020: true, 22 | browser: true, 23 | }, 24 | parser: '@typescript-eslint/parser', 25 | }, 26 | { 27 | files: ['*.vue'], 28 | env: { 29 | es2020: true, 30 | browser: true, 31 | }, 32 | parser: 'vue-eslint-parser', 33 | parserOptions: { 34 | parser: '@typescript-eslint/parser', 35 | }, 36 | globals: { 37 | defineProps: 'readonly', 38 | defineEmits: 'readonly', 39 | defineExpose: 'readonly', 40 | withDefaults: 'readonly', 41 | }, 42 | }, 43 | ], 44 | } 45 | -------------------------------------------------------------------------------- /packages/vote/.postcssrc.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * PostCSS Configuration File 5 | * 6 | * See https://github.com/postcss/postcss#usage 7 | */ 8 | module.exports = { 9 | plugins: [ 10 | // Docs: https://github.com/postcss/autoprefixer#readme 11 | require('autoprefixer'), 12 | // Docs: https://github.com/postcss/postcss-import#readme 13 | require('postcss-import'), 14 | // Docs: https://github.com/postcss/postcss-mixins#readme 15 | require('postcss-mixins'), 16 | // Docs: https://github.com/postcss/postcss-nested#readme 17 | require('postcss-nested'), 18 | ], 19 | preset: { 20 | stage: 1, 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /packages/vote/apollo.config.cjs: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path') 2 | 3 | module.exports = { 4 | client: { 5 | service: { 6 | name: 'PVGQL(local)', 7 | localSchemaFile: resolve(__dirname, './src/graphql/__generated__/schema.graphql'), 8 | }, 9 | includes: [resolve(__dirname, './src/**/*.{graphql,js,ts,jsx,tsx,vue}')], 10 | excludes: [resolve(__dirname, './src/graphql/__generated__/*')], 11 | validationRules: [], // disable validation for now, most of the validations are incompatible with the current graphql plugin. 12 | tagName: 'gql', 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /packages/vote/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 第11回 中文东方人气投票 8 | 9 | 10 | 11 |
12 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /packages/vote/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@touhou-vote/vote", 3 | "private": true, 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "vite build", 7 | "serve": "vite preview", 8 | "codegen": "node ./scripts/codegen.js" 9 | }, 10 | "dependencies": { 11 | "@apollo/client": "^3.7.12", 12 | "@touhou-vote/shared": "workspace:*", 13 | "@vue/apollo-composable": "4.0.0-beta.4", 14 | "@vue/apollo-util": "4.0.0-beta.4", 15 | "@vueuse/components": "^10.0.2", 16 | "@vueuse/core": "^10.0.2", 17 | "bson-objectid": "^2.0.4", 18 | "graphql": "^16.8.1", 19 | "nprogress": "1.0.0-1", 20 | "pinin": "^0.2.0", 21 | "vue": "^3.2.47", 22 | "vue-router": "4.1.6" 23 | }, 24 | "devDependencies": { 25 | "@graphql-codegen/cli": "^3.3.0", 26 | "@graphql-codegen/fragment-matcher": "^4.0.1", 27 | "@graphql-codegen/schema-ast": "^3.0.1", 28 | "@graphql-codegen/typescript": "^3.0.3", 29 | "@iconify/json": "^2.2.52", 30 | "@rollup/plugin-yaml": "^4.0.1", 31 | "@types/nprogress": "^0.2.0", 32 | "@vitejs/plugin-vue": "^4.1.0", 33 | "@windicss/plugin-animations": "^1.0.9", 34 | "autoprefixer": "^10.4.14", 35 | "postcss": "^8.4.31", 36 | "postcss-import": "^15.1.0", 37 | "postcss-mixins": "^9.0.4", 38 | "postcss-nested": "^6.0.1", 39 | "rollup-plugin-visualizer": "^5.9.0", 40 | "ts-poet": "^6.4.1", 41 | "unplugin-icons": "^0.16.1", 42 | "unplugin-vue-components": "^0.24.1", 43 | "vite": "^4.5.3", 44 | "vite-plugin-windicss": "^1.8.10", 45 | "windicss": "^3.5.6" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/vote/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatchyVideo/Touhou-Vote/0f78e2fbc99480bc1fb6a8cc9416cb4ad01fa5fe/packages/vote/public/favicon.ico -------------------------------------------------------------------------------- /packages/vote/public/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "public": false, 4 | "github": { 5 | "enabled": false 6 | }, 7 | "rewrites": [ 8 | { 9 | "source": "/v11-be/(.*)", 10 | "destination": "https://touhou.ai/vote-be/$1" 11 | }, 12 | { 13 | "source": "/(.*)", 14 | "destination": "/index.html" 15 | } 16 | ], 17 | "headers": [ 18 | { 19 | "source": "/manifest.webmanifest", 20 | "headers": [ 21 | { 22 | "key": "content-type", 23 | "value": "application/manifest+json" 24 | } 25 | ] 26 | }, 27 | { 28 | "source": "/LICENSE", 29 | "headers": [ 30 | { 31 | "key": "content-type", 32 | "value": "text/plain" 33 | } 34 | ] 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /packages/vote/scripts/codegen.js: -------------------------------------------------------------------------------- 1 | ;(async () => { 2 | console.log('> GraphQL > Codegen') 3 | await exec('graphql-codegen --config ./src/graphql/codegen.yml') 4 | console.log('\n') 5 | })() 6 | 7 | function exec(cmd) { 8 | const child_process = require('child_process') 9 | return new Promise((resolve) => { 10 | const proc = child_process.spawn('pnpm', ['exec ' + cmd], { 11 | stdio: 'inherit', 12 | shell: true, 13 | }) 14 | proc.on('error', (e) => { 15 | throw e 16 | }) 17 | proc.on('exit', resolve) 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /packages/vote/src/common/assets/Vite App_files/DefaultAvatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatchyVideo/Touhou-Vote/0f78e2fbc99480bc1fb6a8cc9416cb4ad01fa5fe/packages/vote/src/common/assets/Vite App_files/DefaultAvatar.jpg -------------------------------------------------------------------------------- /packages/vote/src/common/assets/loginIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 12 | 13 | 14 | 15 | 16 | 17 | 26 | 27 | 28 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /packages/vote/src/common/assets/logoOldSystem.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatchyVideo/Touhou-Vote/0f78e2fbc99480bc1fb6a8cc9416cb4ad01fa5fe/packages/vote/src/common/assets/logoOldSystem.ico -------------------------------------------------------------------------------- /packages/vote/src/common/assets/logoPatchyVideo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatchyVideo/Touhou-Vote/0f78e2fbc99480bc1fb6a8cc9416cb4ad01fa5fe/packages/vote/src/common/assets/logoPatchyVideo.png -------------------------------------------------------------------------------- /packages/vote/src/common/assets/logoVoilelabs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatchyVideo/Touhou-Vote/0f78e2fbc99480bc1fb6a8cc9416cb4ad01fa5fe/packages/vote/src/common/assets/logoVoilelabs.png -------------------------------------------------------------------------------- /packages/vote/src/common/assets/titleNum.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 26 | 31 | 37 | 38 | -------------------------------------------------------------------------------- /packages/vote/src/common/components/AutoComplete.vue: -------------------------------------------------------------------------------- 1 | 12 | 37 | -------------------------------------------------------------------------------- /packages/vote/src/common/components/BackToHome.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 40 | -------------------------------------------------------------------------------- /packages/vote/src/common/components/Copyright.vue: -------------------------------------------------------------------------------- 1 | 16 | 25 | -------------------------------------------------------------------------------- /packages/vote/src/common/components/GlobalMessages.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 57 | -------------------------------------------------------------------------------- /packages/vote/src/common/components/Mask.vue: -------------------------------------------------------------------------------- 1 | 12 | 40 | 41 | 52 | -------------------------------------------------------------------------------- /packages/vote/src/common/components/NavVote.vue: -------------------------------------------------------------------------------- 1 | 7 | 30 | -------------------------------------------------------------------------------- /packages/vote/src/common/components/VoteCheckBox.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 44 | 55 | -------------------------------------------------------------------------------- /packages/vote/src/common/components/VoteMessageBox.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 54 | 65 | -------------------------------------------------------------------------------- /packages/vote/src/common/components/VoteSelect.vue: -------------------------------------------------------------------------------- 1 | 37 | 89 | -------------------------------------------------------------------------------- /packages/vote/src/common/lib/pinin.ts: -------------------------------------------------------------------------------- 1 | import PinIn, { defaultDict } from 'pinin' 2 | 3 | export const pinin = new PinIn({ 4 | dict: defaultDict, 5 | fuzzy: [ 6 | 'sh|s', 7 | 'ch|c', 8 | 'zh|z', 9 | 'u|v', 10 | 'ang|an', 11 | 'eng|en', 12 | 'ing|in', 13 | 'an>a', 14 | 'ang>a', 15 | 'ai>a', 16 | 'ao>a', 17 | 'ou>o', 18 | 'ong>o', 19 | 'en>e', 20 | 'eng>e', 21 | 'ei>e', 22 | 'er>e', 23 | 'er>r', 24 | 'ui>u', 25 | 'un>u', 26 | 'vn>v', 27 | ], 28 | }) 29 | -------------------------------------------------------------------------------- /packages/vote/src/common/lib/popMessage.ts: -------------------------------------------------------------------------------- 1 | import { shallowRef, triggerRef } from 'vue' 2 | 3 | export interface MessageInfo { 4 | text: string 5 | } 6 | export const messages = shallowRef([]) 7 | 8 | export function popMessageText(text: string): void { 9 | messages.value.push({ text }) 10 | triggerRef(messages) 11 | } 12 | 13 | export interface ConfirmMessageInfo { 14 | text: string 15 | confirmText?: string 16 | cancelText?: string 17 | onConfirm?: () => void 18 | onCancel?: () => void 19 | } 20 | export const confirmMessages = shallowRef([]) 21 | 22 | export function popConfirmText(text: string, confirmText?: string, cancelText?: string): Promise { 23 | return new Promise((resolve) => { 24 | confirmMessages.value.push({ 25 | text, 26 | confirmText, 27 | cancelText, 28 | onConfirm: () => resolve(true), 29 | onCancel: () => resolve(false), 30 | }) 31 | triggerRef(confirmMessages) 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /packages/vote/src/common/lib/setSiteTitle.ts: -------------------------------------------------------------------------------- 1 | import { setSiteTitle } from '@touhou-vote/shared/composables/setSiteTitle' 2 | 3 | export { setSiteTitle } 4 | -------------------------------------------------------------------------------- /packages/vote/src/common/lib/voteYear.ts: -------------------------------------------------------------------------------- 1 | import { voteYear } from '@touhou-vote/shared/data/voteYear' 2 | 3 | export { voteYear } 4 | -------------------------------------------------------------------------------- /packages/vote/src/darkmode/index.ts: -------------------------------------------------------------------------------- 1 | import { useLocalStorage, usePreferredDark } from '@vueuse/core' 2 | import { ref, watch } from 'vue' 3 | 4 | const htmlRoot = document.querySelector('html') 5 | const themeMeta = document.querySelector('meta[name="color-scheme"]') 6 | 7 | const themePreference = useLocalStorage('themePreference', '', { listenToStorageChanges: true, flush: 'post' }) 8 | const themes = ['light', 'dark'] 9 | 10 | function updateDOM(v: boolean) { 11 | if (v) { 12 | htmlRoot?.classList.contains('dark') || htmlRoot?.classList.add('dark') 13 | themeMeta?.setAttribute('content', 'dark') 14 | } else { 15 | htmlRoot?.classList.contains('dark') && htmlRoot?.classList.remove('dark') 16 | themeMeta?.setAttribute('content', 'light') 17 | } 18 | } 19 | 20 | export const isDark = ref(false) 21 | 22 | watch( 23 | [themePreference], 24 | () => { 25 | if (themes.includes(themePreference.value)) { 26 | isDark.value = themePreference.value === 'dark' 27 | } else { 28 | isDark.value = usePreferredDark().value 29 | } 30 | }, 31 | { 32 | flush: 'post', 33 | immediate: true, 34 | } 35 | ) 36 | 37 | watch( 38 | [isDark], 39 | () => { 40 | if (themes.includes(themePreference.value) && isDark.value === usePreferredDark().value) { 41 | themePreference.value = '' 42 | } else if (isDark.value !== usePreferredDark().value) { 43 | themePreference.value = isDark.value ? 'dark' : 'light' 44 | } 45 | updateDOM(isDark.value) 46 | }, 47 | { 48 | flush: 'post', 49 | } 50 | ) 51 | -------------------------------------------------------------------------------- /packages/vote/src/dts/components.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'vue' { 2 | export interface GlobalComponents { 3 | RouterLink: typeof import('vue-router').RouterLink 4 | RouterView: typeof import('vue-router').RouterView 5 | } 6 | } 7 | 8 | export {} 9 | -------------------------------------------------------------------------------- /packages/vote/src/dts/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import type { DefineComponent } from 'vue' 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types 4 | const component: DefineComponent<{}, {}, any> 5 | export default component 6 | } 7 | -------------------------------------------------------------------------------- /packages/vote/src/dts/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/vote/src/end-page/EndPage.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 44 | 45 | -------------------------------------------------------------------------------- /packages/vote/src/end-page/lib/voteEnded.ts: -------------------------------------------------------------------------------- 1 | import { deadline } from '@touhou-vote/shared/data/time' 2 | export const deadlineWithTimezoneOffset = deadline - 8 * 60 * 60 * 1000 - new Date().getTimezoneOffset() * 60 * 1000 3 | export function voteEnded(): boolean { 4 | return new Date(deadlineWithTimezoneOffset).getTime() < Date.now() 5 | } 6 | -------------------------------------------------------------------------------- /packages/vote/src/graphql/codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: 3 | # - https://touhou.vote/v10-be/graphql 4 | - https://touhou.ai/vote-be/graphql 5 | generates: 6 | src/graphql/__generated__/graphql.ts: 7 | plugins: 8 | - typescript 9 | config: 10 | useImplementingTypes: true 11 | addUnderscoreToArgsType: true 12 | nonOptionalTypename: true 13 | scalars: 14 | DateTimeUtc: Date 15 | UtcDateTime: Date 16 | ObjectId: '../index#ObjectID as ObjectId' 17 | src/graphql/__generated__/graphql.fragment.ts: 18 | plugins: 19 | - fragment-matcher 20 | src/graphql/__generated__/schema.graphql: 21 | plugins: 22 | - schema-ast 23 | src/graphql/__generated__/typePolicies.ts: 24 | plugins: 25 | - src/graphql/scripts/codegenPluginTypePolicy.js 26 | config: 27 | scalarTypePolicies: 28 | DateTimeUtc: '../typePolicies#dateTypePolicy' 29 | UtcDateTime: '../typePolicies#dateTypePolicy' 30 | ObjectId: '../typePolicies#objectIdTypePolicy' 31 | -------------------------------------------------------------------------------- /packages/vote/src/graphql/scripts/codegenPluginTypePolicy.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // https://github.com/homebound-team/graphql-typescript-scalar-type-policies/blob/ec2ccd99acb9a64302d133b9afabb90ec62d5420/src/index.ts 3 | 4 | const { isNonNullType, isObjectType, isScalarType } = require('graphql') 5 | const { code, imp } = require('ts-poet') 6 | 7 | /** Generates field policies for user-defined types, i.e. Date handling. */ 8 | // @type import('@graphql-codegen/plugin-helpers').PluginFunction 9 | const plugin = async (schema, _, config) => { 10 | const { scalarTypePolicies = {} } = config || {} 11 | 12 | function isScalarWithTypePolicy(f) { 13 | let type = f.type 14 | if (isNonNullType(type)) { 15 | type = type.ofType 16 | } 17 | return isScalarType(type) && scalarTypePolicies[type.name] !== undefined 18 | } 19 | 20 | const content = await code` 21 | export default { 22 | ${Object.values(schema.getTypeMap()) 23 | .filter(isObjectType) 24 | .filter((t) => !t.name.startsWith('__')) 25 | .filter((t) => Object.values(t.getFields()).some(isScalarWithTypePolicy)) 26 | .map((type) => { 27 | return code`${type.name}: { fields: { ${Object.values(type.getFields()) 28 | .filter(isScalarWithTypePolicy) 29 | .map((field) => { 30 | let type = field.type 31 | if (isNonNullType(type)) { 32 | type = type.ofType 33 | } 34 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 35 | // @ts-ignore 36 | return code`${field.name}: ${toImp(scalarTypePolicies[type.name])},` 37 | })} } },` 38 | })} 39 | }; 40 | `.toString() 41 | return { content } 42 | } 43 | 44 | // Maps the graphql-code-generation convention of `@src/context#Context` to ts-poet's `Context@@src/context`. 45 | function toImp(spec) { 46 | if (!spec) { 47 | return undefined 48 | } 49 | const [path, symbol] = spec.split('#') 50 | return imp(`${symbol}@${path}`) 51 | } 52 | 53 | module.exports = { 54 | plugin, 55 | } 56 | -------------------------------------------------------------------------------- /packages/vote/src/graphql/typePolicies.ts: -------------------------------------------------------------------------------- 1 | import type { FieldPolicy } from '@apollo/client/core' 2 | import { ObjectID } from '.' 3 | 4 | export const dateTypePolicy: FieldPolicy = { 5 | merge: (_, incoming) => { 6 | if (incoming == undefined || incoming == null) { 7 | return incoming 8 | } else if (incoming instanceof Date) { 9 | return incoming 10 | } else { 11 | return new Date(incoming as string) 12 | } 13 | }, 14 | } 15 | 16 | export const objectIdTypePolicy: FieldPolicy = { 17 | merge: (_, incoming) => { 18 | if (incoming == undefined || incoming == null) { 19 | return incoming 20 | } else if (incoming instanceof ObjectID) { 21 | return incoming 22 | } else { 23 | return new ObjectID(incoming as string) 24 | } 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /packages/vote/src/home/HomeEntry.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | -------------------------------------------------------------------------------- /packages/vote/src/home/assets/DefaultAvatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatchyVideo/Touhou-Vote/0f78e2fbc99480bc1fb6a8cc9416cb4ad01fa5fe/packages/vote/src/home/assets/DefaultAvatar.jpg -------------------------------------------------------------------------------- /packages/vote/src/home/components/CompleteTag.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 32 | -------------------------------------------------------------------------------- /packages/vote/src/home/components/UserQuestionnaire.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 57 | -------------------------------------------------------------------------------- /packages/vote/src/home/components/UserQuestionnaireDp.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 42 | -------------------------------------------------------------------------------- /packages/vote/src/home/components/UserVote.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 82 | -------------------------------------------------------------------------------- /packages/vote/src/home/components/UserVoteDp.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 53 | -------------------------------------------------------------------------------- /packages/vote/src/home/lib/questionnaireNameById.ts: -------------------------------------------------------------------------------- 1 | export const questionnaireNameById: Record< 2 | string, 3 | { 4 | name: string 5 | desc: string 6 | children: Record< 7 | string, 8 | { 9 | name: string 10 | desc: string 11 | image: string 12 | } 13 | > 14 | } 15 | > = { 16 | mainQuestionnaire: { 17 | name: '主要问卷', 18 | desc: '您至少需要选择填写两个主要问卷', 19 | children: { 20 | requiredQuestionnaire: { 21 | name: '基础问卷', 22 | desc: '该问卷为必选问卷', 23 | image: 'https://asset.lilywhite.cc/thvote/imgs/vote/home/THBWiki-LOGO-%E5%85%AB%E4%BA%91%E7%B4%AB@100px.png', 24 | }, 25 | optionalQuestionnaire1: { 26 | name: '官方作品问卷', 27 | desc: '适合对官作感兴趣的人填写', 28 | image: 'https://asset.lilywhite.cc/thvote/imgs/vote/home/THBWiki-LOGO-ZUN@100px.png', 29 | }, 30 | optionalQuestionnaire2: { 31 | name: '二次创作问卷', 32 | desc: '适合对二创感兴趣的人填写', 33 | image: 'https://asset.lilywhite.cc/thvote/imgs/vote/home/THBWiki-LOGO-BigSight@100px.png', 34 | }, 35 | }, 36 | }, 37 | extraQuestionnaire: { 38 | name: '额外问卷', 39 | desc: '您至少需要选择填写一个额外问卷', 40 | children: { 41 | exQuestionnaire1: { 42 | name: '同人创作相关', 43 | desc: '适合同人作者填写', 44 | image: 'https://asset.lilywhite.cc/thvote/imgs/vote/home/THBWiki-LOGO-%E7%A5%B8%E7%81%B5%E6%A2%A6@100px.png', 45 | }, 46 | exQuestionnaire2: { 47 | name: '官作游戏相关', 48 | desc: '适合官作游戏玩家填写', 49 | image: 'https://asset.lilywhite.cc/thvote/imgs/vote/home/THBWiki-LOGO-%E9%98%B4%E9%98%B3%E7%8E%89@100px.png', 50 | }, 51 | exQuestionnaire3: { 52 | name: '展会与手游相关', 53 | desc: '适合展会游客或东方手游玩家填写', 54 | image: 55 | 'https://asset.lilywhite.cc/thvote/imgs/vote/home/THBWiki-LOGO-%E8%A7%92%E5%B7%9D%E4%B9%A6%E5%BA%97@100px.png', 56 | }, 57 | exQuestionnaire4: { 58 | name: '正版盗版相关', 59 | desc: '适合对官方与二创作品感兴趣的人填写', 60 | image: 'https://asset.lilywhite.cc/thvote/imgs/vote/home/%E6%82%94%E6%82%9F%E6%A3%92@100px.png', 61 | }, 62 | exQuestionnaire5: { 63 | name: '社群相关/主办方附加', 64 | desc: '适合对东方相关信息感兴趣的人填写', 65 | image: 'https://asset.lilywhite.cc/thvote/imgs/vote/home/Wiki%E6%97%A0%E5%AD%97@100px.png', 66 | }, 67 | }, 68 | }, 69 | } 70 | -------------------------------------------------------------------------------- /packages/vote/src/main/components/AppRouterView.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /packages/vote/src/main/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp, defineComponent, h } from 'vue' 2 | import { createRouter, createWebHistory } from 'vue-router' 3 | import NProgress from 'nprogress' 4 | import AppRouterView from './components/AppRouterView.vue' 5 | import GlobalMessages from '@/common/components/GlobalMessages.vue' 6 | import { createApollo, provideClient } from '@/graphql' 7 | import { checkLoginStatus, isLogin } from '@/home/lib/user' 8 | import { IsQuestionnaireAllDone } from '@/questionnaire/lib/questionnaireData' 9 | import { voteNotStart } from '@/start-page/lib/voteStart' 10 | import { voteEnded } from '@/end-page/lib/voteEnded' 11 | import 'nprogress/css/nprogress.css' 12 | import '@/tailwindcss' 13 | import '@/darkmode' 14 | 15 | // start progress bar 16 | function incProcess() { 17 | if (NProgress.isStarted()) NProgress.inc() 18 | } 19 | NProgress.start() 20 | 21 | // create graphql client 22 | const client = createApollo() 23 | // vue app 24 | const app = createApp( 25 | defineComponent({ 26 | render: () => [h(AppRouterView), h(GlobalMessages)], 27 | setup() { 28 | provideClient(client) 29 | }, 30 | }) 31 | ) 32 | const appPromises: Promise[] = [] 33 | 34 | // check login status 35 | const checkLoginStatusPromise = checkLoginStatus(true) 36 | appPromises.push(checkLoginStatusPromise) 37 | 38 | // router config 39 | declare module 'vue-router' { 40 | interface RouteMeta { 41 | requriequestionnaire?: boolean 42 | availableAfterVoteEnded?: boolean 43 | } 44 | } 45 | const router = createRouter({ 46 | history: createWebHistory('/v11/'), 47 | strict: true, 48 | routes: [ 49 | { 50 | path: '/', 51 | component: () => import('@/home/HomeEntry.vue'), 52 | meta: { availableAfterVoteEnded: true }, 53 | }, 54 | { 55 | path: '/user/settings', 56 | component: () => import('@/home/UserSettings.vue'), 57 | meta: { availableAfterVoteEnded: true }, 58 | }, 59 | { 60 | path: '/questionnaire', 61 | component: () => import('@/questionnaire/Questionnaire.vue'), 62 | }, 63 | { 64 | path: '/vote/character', 65 | component: () => import('@/vote-character/VoteCharacter.vue'), 66 | meta: { requriequestionnaire: true }, 67 | }, 68 | { 69 | path: '/vote/music', 70 | component: () => import('@/vote-music/VoteMusic.vue'), 71 | meta: { requriequestionnaire: true }, 72 | }, 73 | { 74 | path: '/vote/couple', 75 | component: () => import('@/vote-couple/VoteCouple.vue'), 76 | meta: { requriequestionnaire: true }, 77 | }, 78 | { 79 | path: '/doujin', 80 | component: () => import('@/vote-doujin/VoteDoujin.vue'), 81 | meta: { requriequestionnaire: true }, 82 | }, 83 | { 84 | path: '/test', 85 | component: () => import('@/common/TestPage.vue'), 86 | }, 87 | ], 88 | }) 89 | let pendingNProgress: number | undefined 90 | router.beforeEach(async (to, from, next) => { 91 | if (pendingNProgress === undefined) 92 | pendingNProgress = setTimeout(() => { 93 | if (!NProgress.isStarted()) NProgress.start() 94 | pendingNProgress = undefined 95 | }, 150) 96 | 97 | await checkLoginStatusPromise 98 | if (to.path != '/' && voteNotStart()) next({ path: '/' }) 99 | else if (to.path != '/' && !isLogin.value) next({ path: '/' }) 100 | else if (to.meta.availableAfterVoteEnded && voteEnded()) next() 101 | else if (voteEnded()) next({ path: '/' }) 102 | else if (to.meta.requriequestionnaire && !IsQuestionnaireAllDone.value) next({ path: '/' }) 103 | else next() 104 | }) 105 | router.afterEach((guard) => { 106 | incProcess() 107 | appPromisesFinish.then(() => { 108 | if (pendingNProgress) { 109 | clearTimeout(pendingNProgress) 110 | pendingNProgress = undefined 111 | } 112 | if (!guard.meta.holdLoading) { 113 | if (NProgress.isStarted()) NProgress.done() 114 | } 115 | }) 116 | }) 117 | app.use(router) 118 | 119 | const appPromisesFinish = Promise.allSettled(appPromises.map((v) => v.then(incProcess))).then(() => { 120 | app.mount('#app') 121 | incProcess() 122 | }) 123 | -------------------------------------------------------------------------------- /packages/vote/src/questionnaire/lib/questionnaire.ts: -------------------------------------------------------------------------------- 1 | import { questionnaire } from '@touhou-vote/shared/data/questionnaire' 2 | import type { QuestionnaireALL } from '@touhou-vote/shared/data/questionnaire' 3 | 4 | export { QuestionnaireALL, questionnaire } 5 | -------------------------------------------------------------------------------- /packages/vote/src/start-page/StartPage.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 69 | 70 | -------------------------------------------------------------------------------- /packages/vote/src/start-page/lib/voteStart.ts: -------------------------------------------------------------------------------- 1 | import { startTime } from '@touhou-vote/shared/data/time' 2 | export const startTimeWithTimezoneOffset = Math.abs( 3 | startTime - 8 * 60 * 60 * 1000 - new Date().getTimezoneOffset() * 60 * 1000 4 | ) 5 | export function voteNotStart(): boolean { 6 | return new Date(startTimeWithTimezoneOffset).getTime() > Date.now() 7 | } 8 | -------------------------------------------------------------------------------- /packages/vote/src/tailwindcss/index.ts: -------------------------------------------------------------------------------- 1 | import 'virtual:windi.css' 2 | import './global.postcss' 3 | 4 | import { useMediaQuery } from '@vueuse/core' 5 | import { computed, reactive } from 'vue' 6 | 7 | const queries = { 8 | sm: useMediaQuery('(min-width: 640px)'), 9 | md: useMediaQuery('(min-width: 768px)'), 10 | lg: useMediaQuery('(min-width: 1024px)'), 11 | xl: useMediaQuery('(min-width: 1280px)'), 12 | '2xl': useMediaQuery('(min-width: 1536px)'), 13 | '3xl': useMediaQuery('(min-width: 1920px)'), 14 | } 15 | 16 | export const screenSizes = reactive({ 17 | ...queries, 18 | 19 | ' !queries.sm.value), 20 | ' !queries.md.value), 21 | ' !queries.lg.value), 22 | ' !queries.xl.value), 23 | '<2xl': computed(() => !queries['2xl'].value), 24 | '<3xl': computed(() => !queries['3xl'].value), 25 | 26 | '@sm': computed(() => queries.sm.value && !queries.md.value), 27 | '@md': computed(() => queries.md.value && !queries.lg.value), 28 | '@lg': computed(() => queries.lg.value && !queries.xl.value), 29 | '@xl': computed(() => queries.xl.value && !queries['2xl'].value), 30 | '@2xl': computed(() => queries['2xl'].value && !queries['3xl'].value), 31 | '@3xl': computed(() => queries['3xl'].value), 32 | }) 33 | -------------------------------------------------------------------------------- /packages/vote/src/vote-character/assets/defaultCharacterImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatchyVideo/Touhou-Vote/0f78e2fbc99480bc1fb6a8cc9416cb4ad01fa5fe/packages/vote/src/vote-character/assets/defaultCharacterImage.png -------------------------------------------------------------------------------- /packages/vote/src/vote-character/components/AdvancedFilter.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 94 | 105 | -------------------------------------------------------------------------------- /packages/vote/src/vote-character/components/CharacterCard.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 77 | -------------------------------------------------------------------------------- /packages/vote/src/vote-character/components/CharacterHonmeiCard.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 70 | -------------------------------------------------------------------------------- /packages/vote/src/vote-character/lib/character.ts: -------------------------------------------------------------------------------- 1 | import { Character } from '@touhou-vote/shared/data/character' 2 | 3 | export { Character } 4 | 5 | export const character0 = new Character() 6 | -------------------------------------------------------------------------------- /packages/vote/src/vote-character/lib/characterList.ts: -------------------------------------------------------------------------------- 1 | import { computed, ref } from 'vue' 2 | import { CachedSearcher, SearchLogicContain } from 'pinin' 3 | import type { Character } from './character' 4 | import { character0 } from './character' 5 | import { characterHonmei, characters } from './voteData' 6 | import { filterForKind, workSelected } from './workList' 7 | import { pinin } from '@/common/lib/pinin' 8 | import { characterList } from '@touhou-vote/shared/data/character' 9 | 10 | export { characterList } 11 | 12 | export const characterListLeft = computed(() => { 13 | let charaList = characterList.filter((character) => { 14 | let characterInCharacters = false 15 | for (let i = 0; i < characters.value.length; i++) { 16 | if (characters.value[i].id === character.id) characterInCharacters = true 17 | } 18 | return character.id != characterHonmei.value.id && !characterInCharacters 19 | }) 20 | 21 | if (filterForKind.value.length) { 22 | charaList = charaList.filter((chara) => filterForKind.value.find((k1) => chara.kind.find((k2) => k2 === k1.value))) 23 | } 24 | 25 | if (workSelected.value.name) { 26 | charaList = charaList.filter((chara) => chara.work.find((work) => work === workSelected.value.name)) 27 | } 28 | 29 | return charaList 30 | }) 31 | 32 | export const charactersVoted = computed(() => 33 | characters.value.filter((chara) => chara.id != character0.id) 34 | ) 35 | export const charactersVotedWithoutHonmei = computed(() => { 36 | return charactersVoted.value.filter((chara) => !chara.honmei) 37 | }) 38 | 39 | export const orderOptions = [ 40 | { 41 | name: '出场正序', 42 | value: 'newest', 43 | }, 44 | { 45 | name: '出场倒序', 46 | value: 'oldest', 47 | }, 48 | ] 49 | export const order = ref(orderOptions[0]) 50 | export const keyword = ref('') 51 | 52 | const searcher = computed(() => { 53 | const s = new CachedSearcher(SearchLogicContain, pinin) 54 | 55 | for (const c of characterListLeft.value) { 56 | s.put(c.name.toLowerCase(), c) 57 | for (const altname of c.altnames) { 58 | s.put(altname.toLowerCase(), c) 59 | } 60 | for (const work of c.work) { 61 | s.put(work.toLowerCase(), c) 62 | } 63 | } 64 | 65 | return s 66 | }) 67 | export const characterListLeftWithFilter = computed(() => { 68 | const res = keyword.value ? [...new Set(searcher.value.search(keyword.value.toLowerCase()))] : characterListLeft.value 69 | 70 | if (order.value.name === orderOptions[0].name) { 71 | res.sort((a, b) => a.date - b.date) 72 | } else { 73 | res.sort((a, b) => b.date - a.date) 74 | } 75 | 76 | return res 77 | }) 78 | -------------------------------------------------------------------------------- /packages/vote/src/vote-character/lib/voteData.ts: -------------------------------------------------------------------------------- 1 | import { computed, ref, watch } from 'vue' 2 | import { Character } from './character' 3 | import { characterList } from './characterList' 4 | import type { CharacterSubmitQuery } from '@/graphql/__generated__/graphql' 5 | 6 | export const CHARACTERVOTENUM = 8 7 | 8 | // Honmei data, as New Character if no data 9 | // READONLY 10 | export const characterHonmei = computed( 11 | () => characters.value.find((character) => character.honmei) || new Character() 12 | ) 13 | // Vote data, including blank ticket seat (as New Character) 14 | export const characters = ref(new Array(CHARACTERVOTENUM).fill(null).map(() => new Character())) 15 | 16 | watch(characters, setVoteDataCharacters, { deep: true }) 17 | function setVoteDataCharacters(): void { 18 | localStorage.setItem('characters', JSON.stringify(characters.value)) 19 | } 20 | 21 | export function updateVoteCharacters(newVoteData: CharacterSubmitQuery[]): void { 22 | const charactersDataLocal: Character[] = JSON.parse(localStorage.getItem('characters') || '[]') 23 | if (JSON.stringify(charactersDataLocal) != '[]') { 24 | characters.value = charactersDataLocal 25 | } else if (newVoteData.length) { 26 | characters.value = new Array(CHARACTERVOTENUM).fill(null).map(() => new Character()) 27 | for (let i = 0; i < newVoteData.length; i++) { 28 | const characterData = characterList.find((item) => item.id === newVoteData[i].id) || characters.value[i] 29 | characterData.honmei = newVoteData[i].first || false 30 | characterData.reason = newVoteData[i].reason || '' 31 | characters.value[i] = characterData 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/vote/src/vote-character/lib/workList.ts: -------------------------------------------------------------------------------- 1 | import { computed, ref } from 'vue' 2 | import { workList } from '@touhou-vote/shared/data/work' 3 | 4 | interface SelectList { 5 | name: string 6 | value: 'old' | 'new' | 'CD' | 'book' | 'others' | '' 7 | } 8 | 9 | export const kinds: SelectList[] = [ 10 | { name: '旧作', value: 'old' }, 11 | { name: '新作', value: 'new' }, 12 | { name: 'CD', value: 'CD' }, 13 | { name: '出版物', value: 'book' }, 14 | { name: '其他', value: 'others' }, 15 | ] 16 | 17 | export const filterForKind = ref([ 18 | { name: '旧作', value: 'old' }, 19 | { name: '新作', value: 'new' }, 20 | { name: 'CD', value: 'CD' }, 21 | { name: '出版物', value: 'book' }, 22 | { name: '其他', value: 'others' }, 23 | ]) 24 | export const filterForKindTem = ref([ 25 | { name: '旧作', value: 'old' }, 26 | { name: '新作', value: 'new' }, 27 | { name: 'CD', value: 'CD' }, 28 | { name: '出版物', value: 'book' }, 29 | { name: '其他', value: 'others' }, 30 | ]) 31 | export function getFilterForKindTem(): void { 32 | filterForKindTem.value = JSON.parse(JSON.stringify(filterForKind.value)) 33 | } 34 | export function updateFilterForKindTem(kind: SelectList): void { 35 | const index = filterForKindTem.value.findIndex((item) => item.name === kind.name) 36 | index === -1 ? filterForKindTem.value.push(kind) : filterForKindTem.value.splice(index, 1) 37 | } 38 | export function updateFilterForKind(): void { 39 | filterForKind.value = JSON.parse(JSON.stringify(filterForKindTem.value)) 40 | } 41 | export function resetFilterForKindTem(): void { 42 | filterForKindTem.value = [ 43 | { name: '旧作', value: 'old' }, 44 | { name: '新作', value: 'new' }, 45 | { name: 'CD', value: 'CD' }, 46 | { name: '出版物', value: 'book' }, 47 | { name: '其他', value: 'others' }, 48 | ] 49 | } 50 | 51 | export const worksListAfterFilter = computed(() => 52 | workList 53 | .filter((work) => { 54 | let flag = false 55 | for (const kind of filterForKind.value) { 56 | if (work.kind === kind.value) flag = true 57 | } 58 | return flag 59 | }) 60 | .map((work) => { 61 | return { 62 | name: work.name, 63 | value: work.kind, 64 | } 65 | }) 66 | ) 67 | export const workSelected = ref({ name: '', value: '' }) 68 | export const worksListAfterFilterTem = computed(() => 69 | workList 70 | .filter((work) => { 71 | let flag = false 72 | for (const kind of filterForKindTem.value) { 73 | if (work.kind === kind.value) flag = true 74 | } 75 | return flag 76 | }) 77 | .map((work) => { 78 | return { 79 | name: work.name, 80 | value: work.kind, 81 | } 82 | }) 83 | ) 84 | export const workSelectedTem = ref({ name: '', value: '' }) 85 | export function updateWorkSelected(): void { 86 | workSelected.value = workSelectedTem.value 87 | } 88 | export function getWorkSelectedTem(): void { 89 | workSelectedTem.value = workSelected.value 90 | } 91 | export function resetWorkSelectedTem(): void { 92 | workSelectedTem.value = { name: '', value: '' } 93 | } 94 | -------------------------------------------------------------------------------- /packages/vote/src/vote-couple/components/AdvancedFilter.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 95 | 106 | -------------------------------------------------------------------------------- /packages/vote/src/vote-couple/lib/couple.ts: -------------------------------------------------------------------------------- 1 | import { Character } from '@/vote-character/lib/character' 2 | 3 | export class Couple { 4 | valid: boolean 5 | characters: Character[] 6 | seme: number // Serial number of the cp character(0, 1, 2), -1 means no seme character 7 | honmei: boolean 8 | reason: string 9 | constructor( 10 | valid = false, 11 | characters = Array(3) 12 | .fill(null) 13 | .map(() => new Character()), 14 | seme = -1, 15 | honmei = false, 16 | reason = '' 17 | ) { 18 | this.valid = valid 19 | this.characters = characters 20 | this.seme = seme 21 | this.honmei = honmei 22 | this.reason = reason 23 | } 24 | } 25 | export const couple0 = new Couple() 26 | -------------------------------------------------------------------------------- /packages/vote/src/vote-couple/lib/coupleList.ts: -------------------------------------------------------------------------------- 1 | import { computed } from 'vue' 2 | import { Couple } from '@/vote-couple/lib/couple' 3 | import { couples } from '@/vote-couple/lib/voteData' 4 | 5 | export const couplesValid = computed(() => couples.value.filter((couple) => couple.valid)) 6 | 7 | export const coupleHonmei = computed(() => couplesValid.value.find((couple) => couple.honmei) || new Couple()) 8 | 9 | export const couplesValidWithoutHonmei = computed(() => couplesValid.value.filter((couple) => !couple.honmei)) 10 | -------------------------------------------------------------------------------- /packages/vote/src/vote-couple/lib/voteData.ts: -------------------------------------------------------------------------------- 1 | import { computed, ref, watch } from 'vue' 2 | import { Couple } from '@/vote-couple/lib/couple' 3 | import { characterList } from '@/vote-character/lib/characterList' 4 | import type { CpSubmitQuery } from '@/graphql/__generated__/graphql' 5 | import { Character } from '@/vote-character/lib/character' 6 | 7 | // Honmei data, as New Couple if no data 8 | // READONLY 9 | export const CPVOTENUM = 4 10 | 11 | // Vote data, including blank ticket seat (as New Couple) 12 | export const coupleHonmei = computed(() => couples.value.find((couple) => couple.honmei) || new Couple()) 13 | 14 | export const couples = ref(new Array(CPVOTENUM).fill(null).map(() => new Couple())) 15 | 16 | watch(couples, setVoteDataCouples, { deep: true }) 17 | function setVoteDataCouples(): void { 18 | localStorage.setItem('couples', JSON.stringify(couples.value)) 19 | } 20 | 21 | export function updateVotecouple(coupleVoteData: CpSubmitQuery[]): void { 22 | const couplesDataLocal: Couple[] = JSON.parse(localStorage.getItem('couples') || '[]') 23 | if (JSON.stringify(couplesDataLocal) != '[]') { 24 | couples.value = couplesDataLocal 25 | } else if (coupleVoteData.length) { 26 | couples.value = new Array(CPVOTENUM).fill(null).map(() => new Couple()) 27 | for (let i = 0; i < coupleVoteData.length; i++) { 28 | const coupleData = new Couple() 29 | coupleData.characters[0] = characterList.find((item) => item.id === coupleVoteData[i].idA) || new Character() 30 | coupleData.characters[1] = characterList.find((item) => item.id === coupleVoteData[i].idB) || new Character() 31 | if (coupleVoteData[i].idC) 32 | coupleData.characters[2] = characterList.find((item) => item.id === coupleVoteData[i].idC) || new Character() 33 | if (coupleVoteData[i].active) 34 | coupleData.seme = coupleData.characters.findIndex((item) => item.id === coupleVoteData[i].active) 35 | if (coupleVoteData[i].first) coupleData.honmei = true 36 | if (coupleVoteData[i].reason) coupleData.reason = coupleVoteData[i].reason || '' 37 | coupleData.valid = true 38 | couples.value[i] = coupleData 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/vote/src/vote-couple/lib/workList.ts: -------------------------------------------------------------------------------- 1 | import { computed, ref } from 'vue' 2 | import { workList } from '@touhou-vote/shared/data/work' 3 | 4 | interface SelectList { 5 | name: string 6 | value: 'old' | 'new' | 'CD' | 'book' | 'others' | '' 7 | } 8 | 9 | export const kinds: SelectList[] = [ 10 | { name: '旧作', value: 'old' }, 11 | { name: '新作', value: 'new' }, 12 | { name: 'CD', value: 'CD' }, 13 | { name: '出版物', value: 'book' }, 14 | { name: '其他', value: 'others' }, 15 | ] 16 | 17 | export const filterForKind = ref([ 18 | { name: '旧作', value: 'old' }, 19 | { name: '新作', value: 'new' }, 20 | { name: 'CD', value: 'CD' }, 21 | { name: '出版物', value: 'book' }, 22 | { name: '其他', value: 'others' }, 23 | ]) 24 | export const filterForKindTem = ref([ 25 | { name: '旧作', value: 'old' }, 26 | { name: '新作', value: 'new' }, 27 | { name: 'CD', value: 'CD' }, 28 | { name: '出版物', value: 'book' }, 29 | { name: '其他', value: 'others' }, 30 | ]) 31 | export function getFilterForKindTem(): void { 32 | filterForKindTem.value = JSON.parse(JSON.stringify(filterForKind.value)) 33 | } 34 | export function updateFilterForKindTem(kind: SelectList): void { 35 | const index = filterForKindTem.value.findIndex((item) => item.name === kind.name) 36 | index === -1 ? filterForKindTem.value.push(kind) : filterForKindTem.value.splice(index, 1) 37 | } 38 | export function updateFilterForKind(): void { 39 | filterForKind.value = JSON.parse(JSON.stringify(filterForKindTem.value)) 40 | } 41 | export function resetFilterForKindTem(): void { 42 | filterForKindTem.value = [ 43 | { name: '旧作', value: 'old' }, 44 | { name: '新作', value: 'new' }, 45 | { name: 'CD', value: 'CD' }, 46 | { name: '出版物', value: 'book' }, 47 | { name: '其他', value: 'others' }, 48 | ] 49 | } 50 | 51 | export const worksListAfterFilter = computed(() => 52 | workList 53 | .filter((work) => { 54 | let flag = false 55 | for (const kind of filterForKind.value) { 56 | if (work.kind === kind.value) flag = true 57 | } 58 | return flag 59 | }) 60 | .map((work) => { 61 | return { 62 | name: work.name, 63 | value: work.kind, 64 | } 65 | }) 66 | ) 67 | export const workSelected = ref({ name: '', value: '' }) 68 | export const worksListAfterFilterTem = computed(() => 69 | workList 70 | .filter((work) => { 71 | let flag = false 72 | for (const kind of filterForKindTem.value) { 73 | if (work.kind === kind.value) flag = true 74 | } 75 | return flag 76 | }) 77 | .map((work) => { 78 | return { 79 | name: work.name, 80 | value: work.kind, 81 | } 82 | }) 83 | ) 84 | export const workSelectedTem = ref({ name: '', value: '' }) 85 | export function updateWorkSelected(): void { 86 | workSelected.value = workSelectedTem.value 87 | } 88 | export function getWorkSelectedTem(): void { 89 | workSelectedTem.value = workSelected.value 90 | } 91 | export function resetWorkSelectedTem(): void { 92 | workSelectedTem.value = { name: '', value: '' } 93 | } 94 | -------------------------------------------------------------------------------- /packages/vote/src/vote-doujin/components/DoujinCard.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 63 | -------------------------------------------------------------------------------- /packages/vote/src/vote-doujin/components/DoujinCardDp.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 64 | -------------------------------------------------------------------------------- /packages/vote/src/vote-doujin/lib/doujin.ts: -------------------------------------------------------------------------------- 1 | export class Doujin { 2 | dojinType: '' | 'MUSIC' | 'VIDEO' | 'DRAWING' | 'SOFTWARE' | 'ARTICLE' | 'CRAFT' | 'OTHER' 3 | url: string 4 | title: string 5 | author: string 6 | reason: string 7 | imageUrl: string 8 | constructor( 9 | dojinType: '' | 'MUSIC' | 'VIDEO' | 'DRAWING' | 'SOFTWARE' | 'ARTICLE' | 'CRAFT' | 'OTHER' = '', 10 | url = 'https://touhou.vote/', 11 | title = '新版中文东方人气投票', 12 | author = '东方众们', 13 | reason = '只要大家齐心协力,就能向着崭新的明天展翅飞翔,前进吧!', 14 | imageUrl = 'https://asset.lilywhite.cc/thvote/imgs/Cover@100px.png' 15 | ) { 16 | this.dojinType = dojinType 17 | this.url = url 18 | this.title = title 19 | this.author = author 20 | this.reason = reason 21 | this.imageUrl = imageUrl 22 | } 23 | } 24 | export const Doujin0 = new Doujin() 25 | 26 | export const Doujin0NoImageUrl = 'https://asset.lilywhite.cc/thvote/imgs/NoImage@100px.png' 27 | 28 | interface DoujinType { 29 | name: string 30 | value: '' | 'MUSIC' | 'VIDEO' | 'DRAWING' | 'SOFTWARE' | 'ARTICLE' | 'CRAFT' | 'OTHER' 31 | color: string 32 | } 33 | export const doujinTypes: DoujinType[] = [ 34 | { 35 | name: '', 36 | value: '', 37 | color: '#000000', 38 | }, 39 | { 40 | name: '音乐', 41 | value: 'MUSIC', 42 | color: '#4caf50', 43 | }, 44 | { 45 | name: '视频', 46 | value: 'VIDEO', 47 | color: '#FF9800', 48 | }, 49 | { 50 | name: '绘画&设计', 51 | value: 'DRAWING', 52 | color: '#D6C231', 53 | }, 54 | { 55 | name: '游戏&软件', 56 | value: 'SOFTWARE', 57 | color: '#f77194', 58 | }, 59 | { 60 | name: '文字创作', 61 | value: 'ARTICLE', 62 | color: '#0075c5', 63 | }, 64 | { 65 | name: '手工&服饰', 66 | value: 'CRAFT', 67 | color: '#6339b5', 68 | }, 69 | { 70 | name: '其他', 71 | value: 'OTHER', 72 | color: '#733542', 73 | }, 74 | ] 75 | 76 | export function getDoujinTypeData(value: DoujinType['value']): DoujinType { 77 | return doujinTypes.find((v) => v.value === value) || doujinTypes[0] 78 | } 79 | -------------------------------------------------------------------------------- /packages/vote/src/vote-doujin/lib/doujinList.ts: -------------------------------------------------------------------------------- 1 | import { computed } from 'vue' 2 | import type { Doujin } from '@/vote-doujin/lib/doujin' 3 | import { Doujin0 } from '@/vote-doujin/lib/doujin' 4 | import { doujins } from '@/vote-doujin/lib/voteData' 5 | 6 | export const doujinValid = computed(() => 7 | doujins.value.filter((doujin) => doujin.dojinType != Doujin0.dojinType) 8 | ) 9 | -------------------------------------------------------------------------------- /packages/vote/src/vote-doujin/lib/voteData.ts: -------------------------------------------------------------------------------- 1 | import { ref, watch } from 'vue' 2 | import { Doujin, Doujin0 } from '@/vote-doujin/lib/doujin' 3 | import type { DojinSubmitQuery } from '@/graphql/__generated__/graphql' 4 | 5 | export const doujins = ref(new Array(5).fill(null).map(() => new Doujin())) 6 | 7 | watch(doujins, setVoteDataDoujins, { deep: true }) 8 | function setVoteDataDoujins(): void { 9 | localStorage.setItem('doujins', JSON.stringify(doujins.value)) 10 | } 11 | 12 | export function updateVoteDataDoujins(doujinVoteData: DojinSubmitQuery[]): void { 13 | const doujinsDatalocal: Doujin[] = JSON.parse(localStorage.getItem('doujins') || '[]') 14 | if (JSON.stringify(doujinsDatalocal) != '[]') { 15 | doujins.value = doujinsDatalocal 16 | } else if (doujinVoteData.length) { 17 | for (let i = 0; i < doujinVoteData.length; i++) { 18 | const doujinData = new Doujin() 19 | doujinData.author = doujinVoteData[i].author 20 | doujinData.dojinType = doujinVoteData[i].dojinType 21 | doujinData.imageUrl = doujinVoteData[i].imageUrl || Doujin0.imageUrl 22 | doujinData.reason = doujinVoteData[i].reason 23 | doujinData.title = doujinVoteData[i].title 24 | doujinData.url = doujinVoteData[i].url 25 | doujins.value[i] = doujinData 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/vote/src/vote-music/assets/defaultMusicImage.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatchyVideo/Touhou-Vote/0f78e2fbc99480bc1fb6a8cc9416cb4ad01fa5fe/packages/vote/src/vote-music/assets/defaultMusicImage.jpg -------------------------------------------------------------------------------- /packages/vote/src/vote-music/components/AdvancedFilter.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 94 | 105 | -------------------------------------------------------------------------------- /packages/vote/src/vote-music/components/MusicCard.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 75 | -------------------------------------------------------------------------------- /packages/vote/src/vote-music/components/MusicHonmeiCard.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 70 | -------------------------------------------------------------------------------- /packages/vote/src/vote-music/lib/albumList.ts: -------------------------------------------------------------------------------- 1 | import { computed, ref } from 'vue' 2 | import { albumList } from '@touhou-vote/shared/data/music' 3 | 4 | interface SelectList { 5 | name: string 6 | value: 'game' | 'book' | 'CD' | 'others' | '' 7 | } 8 | 9 | export const kinds: SelectList[] = [ 10 | { name: '游戏OST', value: 'game' }, 11 | { name: 'CD', value: 'CD' }, 12 | { name: '出版物', value: 'book' }, 13 | { name: '其他', value: 'others' }, 14 | ] 15 | 16 | export const filterForKind = ref([ 17 | { name: '游戏OST', value: 'game' }, 18 | { name: 'CD', value: 'CD' }, 19 | { name: '出版物', value: 'book' }, 20 | { name: '其他', value: 'others' }, 21 | ]) 22 | export const filterForKindTem = ref([ 23 | { name: '游戏OST', value: 'game' }, 24 | { name: 'CD', value: 'CD' }, 25 | { name: '出版物', value: 'book' }, 26 | { name: '其他', value: 'others' }, 27 | ]) 28 | export function getFilterForKindTem(): void { 29 | filterForKindTem.value = JSON.parse(JSON.stringify(filterForKind.value)) 30 | } 31 | export function updateFilterForKindTem(kind: SelectList): void { 32 | const index = filterForKindTem.value.findIndex((item) => item.name === kind.name) 33 | index === -1 ? filterForKindTem.value.push(kind) : filterForKindTem.value.splice(index, 1) 34 | } 35 | export function updateFilterForKind(): void { 36 | filterForKind.value = JSON.parse(JSON.stringify(filterForKindTem.value)) 37 | } 38 | export function resetFilterForKindTem(): void { 39 | filterForKindTem.value = [ 40 | { name: '游戏OST', value: 'game' }, 41 | { name: 'CD', value: 'CD' }, 42 | { name: '出版物', value: 'book' }, 43 | { name: '其他', value: 'others' }, 44 | ] 45 | } 46 | 47 | export const albumsListAfterFilter = computed(() => 48 | albumList 49 | .filter((album) => { 50 | let flag = false 51 | for (const kind of filterForKind.value) { 52 | if (album.kind === kind.value) flag = true 53 | } 54 | return flag 55 | }) 56 | .map((album) => { 57 | return { 58 | name: album.name, 59 | value: album.kind, 60 | } 61 | }) 62 | ) 63 | export const albumSelected = ref({ name: '', value: '' }) 64 | export const albumsListAfterFilterTem = computed(() => 65 | albumList 66 | .filter((album) => { 67 | let flag = false 68 | for (const kind of filterForKindTem.value) { 69 | if (album.kind === kind.value) flag = true 70 | } 71 | return flag 72 | }) 73 | .map((album) => { 74 | return { 75 | name: album.name, 76 | value: album.kind, 77 | } 78 | }) 79 | ) 80 | export const albumSelectedTem = ref({ name: '', value: '' }) 81 | export function updateAlbumSelected(): void { 82 | albumSelected.value = albumSelectedTem.value 83 | } 84 | export function getAlbumSelectedTem(): void { 85 | albumSelectedTem.value = albumSelected.value 86 | } 87 | export function resetAlbumSelectedTem(): void { 88 | albumSelectedTem.value = { name: '', value: '' } 89 | } 90 | -------------------------------------------------------------------------------- /packages/vote/src/vote-music/lib/music.ts: -------------------------------------------------------------------------------- 1 | import { Music } from '@touhou-vote/shared/data/music' 2 | 3 | export { Music } 4 | 5 | export const music0 = new Music() 6 | -------------------------------------------------------------------------------- /packages/vote/src/vote-music/lib/musicList.ts: -------------------------------------------------------------------------------- 1 | import { computed, ref } from 'vue' 2 | import { CachedSearcher, SearchLogicContain } from 'pinin' 3 | import { music0 } from '@/vote-music/lib/music' 4 | import { musicHonmei, musics } from '@/vote-music/lib/voteData' 5 | import { albumSelected, filterForKind } from '@/vote-music/lib/albumList' 6 | import { pinin } from '@/common/lib/pinin' 7 | import type { Music } from '@touhou-vote/shared/data/music' 8 | import { musicList } from '@touhou-vote/shared/data/music' 9 | 10 | export { musicList } 11 | 12 | export const musicListLeft = computed(() => { 13 | let list = musicList.filter((music) => { 14 | let musicInMusics = false 15 | for (let i = 0; i < musics.value.length; i++) { 16 | if (musics.value[i].id === music.id) musicInMusics = true 17 | } 18 | return music.id != musicHonmei.value.id && !musicInMusics 19 | }) 20 | 21 | if (filterForKind.value.length) { 22 | list = list.filter((music) => filterForKind.value.find((k1) => music.kind.find((k2) => k2 === k1.value))) 23 | } 24 | 25 | if (albumSelected.value.name !== '') { 26 | list = list.filter( 27 | (music) => music.album === albumSelected.value.name || music.include.includes(albumSelected.value.name) 28 | ) 29 | } 30 | return list 31 | }) 32 | 33 | export const musicsVoted = computed(() => musics.value.filter((mus) => mus.id != music0.id)) 34 | export const musicsVotedWithoutHonmei = computed(() => musicsVoted.value.filter((mus) => !mus.honmei)) 35 | 36 | export const orderOptions = [ 37 | { 38 | name: '发布正序', 39 | value: 'newest', 40 | }, 41 | { 42 | name: '发布倒序', 43 | value: 'oldest', 44 | }, 45 | ] 46 | 47 | export const order = ref(orderOptions[0]) 48 | export const keyword = ref('') 49 | 50 | const searcher = computed(() => { 51 | const s = new CachedSearcher(SearchLogicContain, pinin) 52 | 53 | for (const music of musicListLeft.value) { 54 | s.put(music.name.toLowerCase(), music) 55 | s.put(music.album.toLowerCase(), music) 56 | } 57 | 58 | return s 59 | }) 60 | export const musicListLeftWithFilter = computed(() => { 61 | const res = keyword.value ? [...new Set(searcher.value.search(keyword.value.toLowerCase()))] : musicListLeft.value 62 | 63 | if (order.value.name === orderOptions[0].name) { 64 | res.sort((a, b) => a.date - b.date) 65 | } else { 66 | res.sort((a, b) => b.date - a.date) 67 | } 68 | 69 | return res 70 | }) 71 | -------------------------------------------------------------------------------- /packages/vote/src/vote-music/lib/voteData.ts: -------------------------------------------------------------------------------- 1 | import { computed, ref, watch } from 'vue' 2 | import { Music } from '@/vote-music/lib/music' 3 | import { musicList } from '@/vote-music/lib/musicList' 4 | import type { MusicSubmitQuery } from '@/graphql/__generated__/graphql' 5 | 6 | export const MUSICVOTENUM = 12 7 | 8 | // Honmei data, as New Music if no data 9 | // READONLY 10 | export const musicHonmei = computed(() => musics.value.find((music) => music.honmei) || new Music()) 11 | 12 | // Vote data, including blank ticket seat (as New Music) 13 | export const musics = ref(new Array(MUSICVOTENUM).fill(null).map(() => new Music())) 14 | 15 | watch(musics, setVoteDataMusics, { deep: true }) 16 | function setVoteDataMusics(): void { 17 | localStorage.setItem('muiscs', JSON.stringify(musics.value)) 18 | } 19 | 20 | export function updateVoteMusics(newVoteData: MusicSubmitQuery[]): void { 21 | const musicsDataLocal: Music[] = JSON.parse(localStorage.getItem('muiscs') || '[]') 22 | if (JSON.stringify(musicsDataLocal) != '[]') { 23 | musics.value = musicsDataLocal 24 | } else if (newVoteData.length) { 25 | musics.value = new Array(MUSICVOTENUM).fill(null).map(() => new Music()) 26 | for (let i = 0; i < newVoteData.length; i++) { 27 | const musicData = musicList.find((item) => item.id === newVoteData[i].id) || musics.value[i] 28 | musicData.honmei = newVoteData[i].first || false 29 | musicData.reason = newVoteData[i].reason || '' 30 | musics.value[i] = musicData 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/vote/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "exclude": ["dist", "node_modules", "**/__tests__"], 4 | "compilerOptions": { 5 | "lib": ["ESNext", "ESNext.AsyncIterable", "DOM"], 6 | "types": ["vite/client"], 7 | "jsx": "preserve", 8 | "baseUrl": ".", 9 | "paths": { 10 | "@/*": ["./src/*"], 11 | "@@/*": ["./*"] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/vote/vite.config.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { promises as fsp } from 'fs' 3 | import { resolve } from 'path' 4 | import { defineConfig } from 'vite' 5 | import vue from '@vitejs/plugin-vue' 6 | import windicss from 'vite-plugin-windicss' 7 | import components from 'unplugin-vue-components/vite' 8 | import icons from 'unplugin-icons/vite' 9 | import iconsResolver from 'unplugin-icons/resolver' 10 | import { visualizer } from 'rollup-plugin-visualizer' 11 | import yaml from '@rollup/plugin-yaml' 12 | 13 | /** 14 | * Vite Configuration File 15 | * 16 | * Docs: https://vitejs.dev/config/ 17 | */ 18 | export default defineConfig(async ({ command, mode }) => { 19 | /* create __generated__ dir */ 20 | { 21 | const list = ['dts'] 22 | const promises = [] 23 | for (const dir of list) promises.push(fsp.mkdir(resolve(__dirname, `./src/${dir}/__generated__`))) 24 | await Promise.allSettled(promises) 25 | } 26 | 27 | return { 28 | optimizeDeps: { 29 | exclude: ['@touhou-vote/shared'], 30 | }, 31 | resolve: { 32 | alias: { 33 | '@/': `${resolve(__dirname, './src/')}/`, 34 | '@@/': `${resolve(__dirname, './')}/`, 35 | }, 36 | }, 37 | plugins: [ 38 | yaml(), 39 | vue(), 40 | windicss(), 41 | components({ 42 | dirs: [], 43 | resolvers: [ 44 | iconsResolver({ 45 | componentPrefix: 'icon', 46 | }), 47 | ], 48 | dts: resolve(__dirname, './src/dts/__generated__/viteComponents.d.ts'), 49 | }), 50 | icons(), 51 | { 52 | ...visualizer({ 53 | filename: 'dist/stats.html', 54 | gzipSize: true, 55 | brotliSize: true, 56 | }), 57 | apply: 'build', 58 | }, 59 | ], 60 | server: { 61 | proxy: { 62 | '/v11-be': { 63 | target: 'https://touhou.ai/vote-be', 64 | changeOrigin: true, 65 | secure: false, 66 | rewrite: (path) => path.replace(/^\/v11-be/, ''), 67 | }, 68 | }, 69 | }, 70 | build: { 71 | sourcemap: true, 72 | assetsDir: 'v11/assets', 73 | }, 74 | esbuild: { 75 | charset: 'utf8', 76 | }, 77 | } 78 | }) 79 | -------------------------------------------------------------------------------- /packages/vote/windi.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'windicss/helpers' 2 | import typography from 'windicss/plugin/typography' 3 | import lineClamp from 'windicss/plugin/line-clamp' 4 | import aspectRatio from 'windicss/plugin/aspect-ratio' 5 | import animation from '@windicss/plugin-animations' 6 | 7 | /** 8 | * WindiCSS Configuration File 9 | * 10 | * Docs: https://windicss.org/guide/configuration.html 11 | */ 12 | export default defineConfig({ 13 | extract: { 14 | include: ['src/**/*.{html,vue,ts,tsx,js,jsx,css}'], 15 | }, 16 | darkMode: 'class', 17 | theme: { 18 | extend: { 19 | colors: { 20 | // accent-color-600 is the default accent color of the project 21 | // accent-color-300 is recommended to used for text 22 | 'accent-color': { 23 | 100: '#D6CEE6', 24 | 200: '#D6C2F7', 25 | 300: '#BDA6EF', 26 | 400: '#AD8EEF', 27 | 500: '#9C75EF', 28 | 600: '#7351C5', 29 | 700: '#5A41A4', 30 | 800: '#4A317B', 31 | 900: '#312052', 32 | }, 33 | subaccent: '#3A3531', 34 | // Color for divider in home page 35 | 'subaccent-divider': '#5a5a56', 36 | textaccent: '#CEBEAD', 37 | }, 38 | screens: { 39 | '3xl': '1920px', 40 | }, 41 | boxShadow: { 42 | around: '5px 20px 25px rgba(0, 0, 0, 0.1), -3px -3px 10px rgba(0, 0, 0, 0.1)', 43 | }, 44 | }, 45 | }, 46 | plugins: [ 47 | // https://github.com/tailwindlabs/tailwindcss-typography#usage 48 | typography, 49 | lineClamp, 50 | aspectRatio, 51 | animation, 52 | ], 53 | }) 54 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/* 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["dist", "node_modules", "./packages/vote/**", "./packages/navigator/**"], 3 | "compilerOptions": { 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "lib": ["ESNext", "ESNext.AsyncIterable"], 7 | "types": [], 8 | "target": "ES2018", 9 | "sourceMap": true, 10 | "noImplicitAny": true, 11 | "noUnusedLocals": true, 12 | "strict": true, 13 | "resolveJsonModule": true, 14 | "esModuleInterop": true 15 | } 16 | } 17 | --------------------------------------------------------------------------------