├── .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 |
5 |
6 |
7 |
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 |
2 |
3 |
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 |
2 | Not Found
3 |
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 |
2 |
3 |
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 |
37 |
--------------------------------------------------------------------------------
/packages/result/src/components/GraphEvolution.vue:
--------------------------------------------------------------------------------
1 |
2 |
121 |
122 |
--------------------------------------------------------------------------------
/packages/result/src/components/GraphPie.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 | 点击这里切换图表类型
8 |
9 |
10 |
11 |
12 |
105 |
106 |
--------------------------------------------------------------------------------
/packages/result/src/components/GraphSunburst.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 | 点击这里切换图表类型
8 |
9 |
10 |
11 |
12 |
128 |
129 |
--------------------------------------------------------------------------------
/packages/result/src/components/NavTop.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
{{ '投票结果 - 第' + voteYear + '回 中文东方人气投票' }}
5 |
6 |
7 |
8 |
9 |
10 |
31 |
32 |
33 |
34 |
35 |
36 |
126 |
127 |
--------------------------------------------------------------------------------
/packages/result/src/components/VoteSelect.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 | {{ selected.name === '' ? selectedName : selected.name }}
10 |
11 |
15 | ▼
16 |
17 |
18 |
38 |
39 |
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 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/packages/result/src/layouts/default.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Copyright © 2022 - 2023 THBWiki, VoileLabs. All rights reserved. Licensed under GPL-3.0.
7 |
8 |
12 |
13 |
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 |
2 |
3 |
6 |
7 |

11 |
CP部门
12 |
目录
13 |
14 |
15 |
参投CP总数
16 |
总本命票数
17 |
总有效票数
18 |
{{ totalUniqueItemsCouple }}
19 |
{{ totalFirstCouple }}
20 |
{{ totalVotesCouple }}
21 |
22 |
23 |
24 |
25 | *本页面为人气投票CP部门的结果目录,请点击上面的对应项目查看详细数据。
26 | *本届与上一届相同,数据展示的结构和层次均进行了重新的设计,本届的结果都在一个页面内集中展示。
27 |
28 |
29 |
30 |
31 |
37 | {{ item.title }}
38 | {{ item.desc }}
39 |
40 |
41 |
42 |
43 |
44 |
45 |
108 |
109 |
110 | meta:
111 | navid: couple
112 |
113 |
--------------------------------------------------------------------------------
/packages/result/src/pages/Music.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |

11 |
音乐部门
12 |
目录
13 |
14 |
15 |
参投曲目总数
16 |
总本命票数
17 |
总有效票数
18 |
{{ totalUniqueItemsMusic }}
19 |
{{ totalFirstMusic }}
20 |
{{ totalVotesMusic }}
21 |
22 |
23 |
24 |
25 | *本页面为人气投票音乐部门的结果目录,请点击上面的对应项目查看详细数据。
26 | *本届与上一届相同,数据展示的结构和层次均进行了重新的设计,本届的结果都在一个页面内集中展示。
27 |
28 |
29 |
30 |
31 |
37 | {{ item.title }}
38 | {{ item.desc }}
39 |
40 |
41 |
42 |
43 |
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 |
2 | {{ getIDtoCharacterName() }}
3 | {{ getIDtoMusicName() }}
4 |
5 |
8 |
--------------------------------------------------------------------------------
/packages/result/src/pages/[...all].vue:
--------------------------------------------------------------------------------
1 |
2 | Not Found
3 |
4 |
--------------------------------------------------------------------------------
/packages/result/src/pages/character.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |

11 |
角色部门
12 |
目录
13 |
14 |
15 |
参投角色总数
16 |
总本命票数
17 |
总有效票数
18 |
{{ totalUniqueItemsCharacter }}
19 |
{{ totalFirstCharacter }}
20 |
{{ totalVotesCharacter }}
21 |
22 |
23 |
24 |
25 | *本页面为人气投票角色部门的结果目录,请点击上面的对应项目查看详细数据。
26 | *本届与上一届相同,数据展示的结构和层次均进行了重新的设计,本届的结果都在一个页面内集中展示。
27 |
28 |
29 |
30 |
31 |
37 | {{ item.title }}
38 | {{ item.desc }}
39 |
40 |
41 |
42 |
43 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |

12 | THBWiki &
13 |

14 | VoileLabs
15 |
16 |
17 |
18 |

19 |

20 |
21 |
22 |
23 |
24 |
25 |
26 |
感谢各位的参与,本次人气投票活动已圆满结束
27 |
所有的投票结果将发布在本站内,请点击下面的链接进入相应的部分查看结果数据
28 |
*请注意,一切在本站之外发表的排行、分析、视频均为非官方性质
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
44 |
45 | {{ item.name }}
46 |
47 |
48 |
49 |
50 |
51 |
52 |
56 |
© Copyright 2022 THBWiki, VoileLabs. Licensed under GPL-3.0.
57 |
58 |
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 |
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 |
38 |
--------------------------------------------------------------------------------
/packages/vote/src/common/components/AutoComplete.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
12 |
37 |
--------------------------------------------------------------------------------
/packages/vote/src/common/components/BackToHome.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
13 |
14 |
15 |
40 |
--------------------------------------------------------------------------------
/packages/vote/src/common/components/Copyright.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 | Copyright © 2022 - 2023 THBWiki, VoileLabs. All rights reserved. Licensed under GPL-3.0.
10 |
14 |
15 |
16 |
25 |
--------------------------------------------------------------------------------
/packages/vote/src/common/components/GlobalMessages.vue:
--------------------------------------------------------------------------------
1 |
2 | closeMsg(msg)"
9 | >
11 | closeConfirm(msg)"
17 | >
18 |
19 |
20 |
30 |
40 |
41 |
42 |
43 |
44 |
57 |
--------------------------------------------------------------------------------
/packages/vote/src/common/components/Mask.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
12 |
40 |
41 |
52 |
--------------------------------------------------------------------------------
/packages/vote/src/common/components/NavVote.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
{{ '第' + voteYear + '回 中文东方人气投票 - ' + typeChinese }}
5 |
6 |
7 |
30 |
--------------------------------------------------------------------------------
/packages/vote/src/common/components/VoteCheckBox.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
12 |
16 |
17 |
18 |
19 |
44 |
55 |
--------------------------------------------------------------------------------
/packages/vote/src/common/components/VoteMessageBox.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
{{ props.title }}
9 |
10 |
11 |
12 |
(open = false)">
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
54 |
65 |
--------------------------------------------------------------------------------
/packages/vote/src/common/components/VoteSelect.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 | {{ selected.name === '' ? selectedName : selected.name }}
9 |
13 | ▼
14 |
15 |
16 |
35 |
36 |
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 |
2 |
3 |
4 |
7 |
8 |
9 |

THBWiki &
10 |

VoileLabs
11 |
12 |
16 |
17 |
18 |
19 | 投票已经结束,如果您想修改自己的账户信息,请点击
这里登录
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
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 |
2 |
5 |
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 |
2 |
3 |
8 | 完成
9 |
10 |
15 | 未完成
16 |
17 |
18 |
19 |
20 |
32 |
--------------------------------------------------------------------------------
/packages/vote/src/home/components/UserQuestionnaire.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
{{ questionnaireNameById[catogory].name }}
8 | {{ questionnaireNameById[catogory].desc }}
9 |
10 |
16 |
17 |
18 |
![]()
19 |
20 |
21 |
22 |
23 |
24 |
25 | {{ questionnaireNameById[catogory].children[childId].name }}
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
57 |
--------------------------------------------------------------------------------
/packages/vote/src/home/components/UserQuestionnaireDp.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | 问卷填写完成了!您可以继续填写,或在左栏选择“参与投票”开始投票
6 |
7 |
在开始投票之前,您需要至少填写下列问卷中的3个(2+1):
8 |
9 |
10 |
11 |
12 |
13 |
14 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
42 |
--------------------------------------------------------------------------------
/packages/vote/src/home/components/UserVote.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
13 |
![]()
14 |
15 |
16 |
17 |
18 |
19 |
{{ item.title }}
20 |
21 |
22 |
{{ item.desc }}
23 |
24 |
25 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
82 |
--------------------------------------------------------------------------------
/packages/vote/src/home/components/UserVoteDp.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
参与投票
5 | 为您喜爱的角色/曲目/CP投上一票吧!
6 |
7 |
8 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
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 |
2 |
3 |
4 |
7 |
8 |
9 |

THBWiki &
10 |

VoileLabs
11 |
12 |
16 |
17 |
20 |
距开始还有
21 |
{{ daysWith0 }}
22 |
天
23 |
{{ hoursWith0 }}
24 |
时
25 |
{{ minutesWith0 }}
26 |
分
27 |
{{ secondsWith0 }}
28 |
秒
29 |
30 |
31 |
32 |
33 |
34 |
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 |
2 |
3 |
7 |
11 |
12 |
初登场作品种类:
13 |
14 |
24 | {{ kind.name }}
25 |
26 |
27 |
初登场作品:
28 |
29 |
33 | 重置条件
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
94 |
105 |
--------------------------------------------------------------------------------
/packages/vote/src/vote-character/components/CharacterCard.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
![]()
7 |
8 |
9 |
10 | {{ character.name }}
11 |
12 |
13 |
14 |
15 |
16 |
25 |
26 |
27 |
28 |
29 |
30 |
77 |
--------------------------------------------------------------------------------
/packages/vote/src/vote-character/components/CharacterHonmeiCard.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
![]()
6 |
7 |
8 |
9 |
10 |
11 |
{{ characterHonmei.title }}
12 |
13 | {{ characterHonmei.name }}
14 |
15 |
24 |
25 |
32 |
33 |
34 |
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 |
2 |
3 |
7 |
11 |
12 |
初登场作品种类:
13 |
14 |
24 | {{ kind.name }}
25 |
26 |
27 |
初登场作品:
28 |
29 |
33 | 重置条件
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
12 |
13 |
14 |
15 |
16 |
36 |
37 |
{{ doujin.reason }}
38 |
39 |
40 |
41 |
63 |
--------------------------------------------------------------------------------
/packages/vote/src/vote-doujin/components/DoujinCardDp.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
8 |
9 |
14 |
15 |
16 |
17 |
18 |
38 |
39 |
40 |
41 |
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 |
2 |
3 |
7 |
11 |
12 |
收录专辑的种类:
13 |
14 |
24 | {{ kind.name }}
25 |
26 |
27 |
收录于专辑:
28 |
29 |
33 | 重置条件
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
94 |
105 |
--------------------------------------------------------------------------------
/packages/vote/src/vote-music/components/MusicCard.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
![]()
6 |
7 |
8 | {{ music.name }}
9 |
10 |
11 |
12 |
13 |
14 |
23 |
24 |
25 |
26 |
27 |
28 |
75 |
--------------------------------------------------------------------------------
/packages/vote/src/vote-music/components/MusicHonmeiCard.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
![]()
6 |
7 |
8 |
9 |
10 |
11 |
{{ musicHonmei.album }}
12 |
13 | {{ musicHonmei.name }}
14 |
15 |
24 |
25 |
32 |
33 |
34 |
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 |
--------------------------------------------------------------------------------