From 953b1d40750a1ad825581371128626fe71af315c Mon Sep 17 00:00:00 2001 From: Renko_0ac9 <1471850534@qq.com> Date: Mon, 9 Feb 2026 03:55:38 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(vote):=20:art:=20f=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E6=8A=95=E7=A5=A8=E7=BB=93=E6=9E=9C=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E4=B8=BA=E5=9B=BE=E7=89=87=E5=8A=9F=E8=83=BD(?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E9=83=A8=E5=88=86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在后端API写出来之前,我先在前端写了一个导出数据的组件,目前使用mock data进行测试,同时顺便写了一个绕过后端登录逻辑访问页面和配置数据的testHelper.ts工具函数。具体log请见:feat: 添加角色投票结果导出为图片功能(前端部分) ## 新增功能 - 导出角色投票结果为图片(html2canvas)的组件 - 测试辅助工具(testHelper.ts)支持快速设置测试数据(因为我不确定后端要怎么设计,所以先做了一个临时实现,后续对接后端 API 时需要修改) - 资源 URL 处理工具(assetUrl.ts)解决开发环境跨域问题 - 优化角色搜索和排序逻辑(characterSearch.ts) ## 依赖更新 - 添加 html2canvas@^1.4.1 - 升级 @apollo/client@^3.11.8 - 升级 graphql@^16.9.0 ## Bug 修复 - 优化角色列表搜索性能,把不同地方的搜索逻辑合并到一个工具函数中 - 修复 Vite 配置 TypeScript 类型错误 --- ## ⚠️ 上线前必须修改的配置 1. **投票截止时间** (`packages/shared/data/time.ts`) ```typescript // 改回:new Date(2024, 0, 15).getTime() ``` 2. **GraphQL Schema** (`packages/vote/src/graphql/codegen.yml`) ```yaml # 改回:https://touhou.ai/vote-be/graphql ``` 3. Vite 代理配置仅用于开发环境,生产环境自动忽略,需要配置服务器那边的CORS策略 --- ## 测试方式 ```javascript testHelper.setupQuickTestVotes() // 首页 → 点击头像 → 导出角色投票为图片 --- .gitignore | 7 +- packages/shared/data/time.ts | 5 +- packages/vote/TESTING_GUIDE.md | 282 +++++++++++++++++ packages/vote/package.json | 5 +- .../components/ExportCharacterVoteImage.vue | 288 ++++++++++++++++++ packages/vote/src/common/lib/assetUrl.ts | 62 ++++ .../vote/src/common/lib/characterSearch.ts | 75 +++++ .../vote/src/common/lib/exportVoteData.ts | 62 ++++ packages/vote/src/common/lib/testHelper.ts | 205 +++++++++++++ packages/vote/src/graphql/codegen.yml | 2 +- packages/vote/src/home/UserHome.vue | 7 + packages/vote/src/home/lib/user.ts | 45 ++- packages/vote/src/main/main.ts | 7 +- .../src/vote-character/lib/characterList.ts | 49 +-- .../components/CharacterSelect.vue | 46 +-- packages/vote/src/vote-music/lib/voteData.ts | 4 +- packages/vote/vite.config.ts | 63 ++-- pnpm-lock.yaml | 43 ++- 18 files changed, 1139 insertions(+), 118 deletions(-) create mode 100644 packages/vote/TESTING_GUIDE.md create mode 100644 packages/vote/src/common/components/ExportCharacterVoteImage.vue create mode 100644 packages/vote/src/common/lib/assetUrl.ts create mode 100644 packages/vote/src/common/lib/characterSearch.ts create mode 100644 packages/vote/src/common/lib/exportVoteData.ts create mode 100644 packages/vote/src/common/lib/testHelper.ts diff --git a/.gitignore b/.gitignore index 6e2ddb9..dec8c38 100644 --- a/.gitignore +++ b/.gitignore @@ -132,5 +132,8 @@ __generated__ # vscode settings .vscode/ -# github settings -.github/ +# commit message +msg.txt + +# 我自己的本地总结文档文件夹 +local_summary/ \ No newline at end of file diff --git a/packages/shared/data/time.ts b/packages/shared/data/time.ts index e269c7c..ca1a7cd 100644 --- a/packages/shared/data/time.ts +++ b/packages/shared/data/time.ts @@ -3,7 +3,10 @@ export const startTime = new Date(2023, 11, 29, 18).getTime() // Deadline: 2024/1/15 00:00:00 UTC+8 // Notice that month start at "0", not "1", so January is "0" -export const deadline = new Date(2024, 0, 15).getTime() +//export const deadline = new Date(2024, 0, 15).getTime() + +// 为了开发方便,我暂时需要把Deadline设置得比较远,等到投票阶段开始前再修改回来 +export const deadline = new Date(2099, 1, 15).getTime() export function timeFormat(date: Date): string { return date.getFullYear() + '-' + (date.getMonth() + 1) + '-' + date.getDate() + ' ' + date.getHours() + ': 00' } diff --git a/packages/vote/TESTING_GUIDE.md b/packages/vote/TESTING_GUIDE.md new file mode 100644 index 0000000..cfee18d --- /dev/null +++ b/packages/vote/TESTING_GUIDE.md @@ -0,0 +1,282 @@ +# 测试环境使用指南 + +## 概述 + +本指南帮助你在开发和测试环境中模拟用户登录和设置测试数据,以便测试投票导出功能。 + +## 前置条件 + +确保你在开发环境中运行项目: +```bash +cd packages/vote +pnpm dev +``` + +## 快速开始 + +### 方式一:使用浏览器控制台(推荐) + +1. 打开浏览器,访问 `http://localhost:5173/v11/` +2. 打开浏览器开发者工具(F12) +3. 切换到 Console(控制台)标签 +4. 你会看到测试工具已加载的提示信息 +5. 输入以下命令: + +```javascript +testHelper.setupQuickTestVotes() +``` + +这条命令会: +- 自动登录一个测试用户 +- 设置角色投票数据(博丽灵梦为本命,其他7个角色) +- 所有数据会自动保存到 localStorage + +6. 页面会自动刷新,你会看到已登录状态 +7. 点击右上角头像,在用户菜单中点击"导出角色投票为图片" +8. 预览并下载图片 + +### 方式二:访问测试页面 + +访问 `http://localhost:5173/v11/test` 可以看到现有的测试页面。 + +## 可用的测试命令 + +### 1. 快速设置完整测试数据 + +```javascript +testHelper.setupQuickTestVotes() +``` + +一键设置: +- 测试用户登录 +- 本命角色:博丽灵梦 +- 其他角色:雾雨魔理沙、琪露诺、十六夜咲夜等 + +### 2. 仅设置测试用户 + +```javascript +testHelper.setupTestUser() +``` + +只创建一个登录用户,不设置投票数据。 + +### 3. 自定义角色投票 + +```javascript +testHelper.setupTestCharacterVotes('灵梦', ['魔理沙', '琪露诺']) +``` + +参数: +- 第一个参数:本命角色名称(可选) +- 第二个参数:其他角色名称数组(可选) + +示例: +```javascript +// 只有本命 +testHelper.setupTestCharacterVotes('博丽灵梦') + +// 自定义多个角色 +testHelper.setupTestCharacterVotes('博丽灵梦', ['雾雨魔理沙', '琪露诺', '十六夜咲夜']) +``` + +### 4. 查看可用角色 + +```javascript +testHelper.getAvailableCharacters() +``` + +显示前20个可用的角色列表(包含灵梦、魔理沙、琪露诺相关角色)。 + +### 5. 检查当前状态 + +```javascript +testHelper.checkTestStatus() +``` + +显示: +- 登录状态 +- Token 信息 +- 用户名 +- 角色投票数量和名称 + +### 6. 清理测试数据 + +```javascript +testHelper.clearTestUserData() +``` + +清除所有测试数据并刷新页面。 + +## 测试场景示例 + +### 场景1:只有本命角色 + +```javascript +// 设置测试用户 +testHelper.setupTestUser() + +// 只设置本命角色 +testHelper.setupTestCharacterVotes('博丽灵梦') + +// 测试导出功能 +``` + +### 场景2:没有投票数据 + +```javascript +// 只设置测试用户,不设置投票 +testHelper.setupTestUser() + +// 测试无数据时的显示 +``` + +### 场景3:多个投票数据 + +```javascript +// 快速设置8个角色投票 +testHelper.setupQuickTestVotes() + +// 查看当前状态 +testHelper.checkTestStatus() +``` + +### 场景4:自定义角色 + +```javascript +// 查看可用角色 +testHelper.getAvailableCharacters() + +// 自定义投票 +testHelper.setupTestCharacterVotes('十六夜咲夜', ['蕾米莉亚', '芙兰朵露']) +``` + +## 常见问题 + +### Q1: 命令找不到? + +**A:** 确保: +1. 在开发环境运行项目(`pnpm dev`) +2. 页面已经加载完成 +3. 在浏览器的 Console 中输入命令 + +### Q2: 角色名称不匹配? + +**A:** 使用 `testHelper.getAvailableCharacters()` 查看可用的角色名称,使用完整名称或部分名称。 + +### Q3: 如何重置所有数据? + +**A:** 使用 `testHelper.clearTestUserData()` 清理所有测试数据。 + +### Q4: 生产环境能用吗? + +**A:** 不能!测试工具仅在开发环境(`import.meta.env.DEV`)中加载,生产环境不会加载。 + +### Q5: 设置的数据会持久化吗? + +**A:** 会。所有数据都保存在 localStorage 中,刷新页面不会丢失。 + +## 测试导出功能 + +### 完整测试流程 + +1. **设置测试数据** + ```javascript + testHelper.setupQuickTestVotes() + ``` + +2. **页面会自动刷新** + +3. **进入用户主页** + - 访问 `http://localhost:5173/v11/` + - 页面应该显示已登录状态 + +4. **打开导出功能** + - 点击右上角头像 + - 在用户菜单中找到"导出角色投票为图片"按钮 + +5. **测试导出** + - 点击按钮,预览卡片 + - 点击"生成并下载" + - 检查下载的 PNG 图片 + +6. **检查图片内容** + - 头部:第X回 中文东方人气投票 + - 本命角色:博丽灵梦 + - 其他角色:雾雨魔理沙等 + - 底部:生成时间和品牌信息 + +### 测试检查清单 + +- [ ] 登录功能正常 +- [ ] 用户菜单显示导出按钮 +- [ ] 导出对话框正常打开 +- [ ] 预览卡片显示正确 +- [ ] 本命角色突出显示 +- [ ] 其他角色列表正确 +- [ ] 投票理由正常显示 +- [ ] 生成图片成功 +- [ ] 图片下载成功 +- [ ] 图片质量清晰 +- [ ] 无投票数据时显示提示 + +## 手动设置数据(高级) + +如果你想手动设置数据,可以在控制台直接操作: + +```javascript +// 设置登录 +import { voteToken, setUserDataToLocalStorage, user, createDefaultVoter } from '@/home/lib/user' +const testUser = { + ...createDefaultVoter(), + username: '自定义用户', + phone: '138****8888' +} +setUserDataToLocalStorage(testUser, 'test_token', 'test_session') + +// 设置角色投票 +import { characters } from '@/vote-character/lib/voteData' +import { Character } from '@/vote-character/lib/character' +const newChar = new Character() +newChar.id = '12345' +newChar.name = '测试角色' +newChar.honmei = true +newChar.reason = '测试理由' +characters.value[0] = newChar +localStorage.setItem('characters', JSON.stringify(characters.value)) + +// 刷新页面 +location.reload() +``` + +## 清理和重置 + +### 完全清理 + +```javascript +testHelper.clearTestUserData() +``` + +### 部分清理 + +```javascript +// 只清理角色投票 +localStorage.removeItem('characters') +location.reload() + +// 只清理用户登录 +localStorage.removeItem('user') +localStorage.removeItem('voteToken') +localStorage.removeItem('sessionToken') +location.reload() +``` + +## 注意事项 + +1. **仅用于开发环境**:测试工具不会在生产环境加载 +2. **数据持久化**:数据保存在 localStorage,手动清理才会删除 +3. **不影响后端**:这是纯前端模拟,不会影响后端数据 +4. **Token 无效**:模拟的 token 无法通过后端验证 +5. **刷新生效**:设置数据后需要刷新页面才能看到效果 + +## 后续测试 + diff --git a/packages/vote/package.json b/packages/vote/package.json index 59daeec..56df067 100644 --- a/packages/vote/package.json +++ b/packages/vote/package.json @@ -8,14 +8,15 @@ "codegen": "node ./scripts/codegen.js" }, "dependencies": { - "@apollo/client": "^3.7.12", + "@apollo/client": "^3.11.8", "@touhou-vote/shared": "workspace:*", "@vue/apollo-composable": "4.0.0-beta.4", "@vue/apollo-util": "4.0.0-beta.4", "@vueuse/components": "^10.0.2", "@vueuse/core": "^10.0.2", "bson-objectid": "^2.0.4", - "graphql": "^16.8.1", + "graphql": "^16.9.0", + "html2canvas": "^1.4.1", "nprogress": "1.0.0-1", "pinin": "^0.2.0", "vue": "^3.2.47", diff --git a/packages/vote/src/common/components/ExportCharacterVoteImage.vue b/packages/vote/src/common/components/ExportCharacterVoteImage.vue new file mode 100644 index 0000000..1b33da0 --- /dev/null +++ b/packages/vote/src/common/components/ExportCharacterVoteImage.vue @@ -0,0 +1,288 @@ + + + diff --git a/packages/vote/src/common/lib/assetUrl.ts b/packages/vote/src/common/lib/assetUrl.ts new file mode 100644 index 0000000..9362560 --- /dev/null +++ b/packages/vote/src/common/lib/assetUrl.ts @@ -0,0 +1,62 @@ +/** + * 资源 URL 处理工具 + * + * + * 开发环境代理说明: + * - 第三方服务器 asset.lilywhite.cc 禁止跨域请求 + * - 开发环境下,我们通过 Vite 代理将 /th-assets 路径代理到 https://asset.lilywhite.cc + * - 这样可以避免 CORS 错误,同时保持生产环境的灵活性 + * + * 代理配置位置:packages/vote/vite.config.ts + * + * 使用方式: + * - 原始 URL: https://asset.lilywhite.cc/character/1.png + * - 开发环境: /th-assets/character/1.png (通过代理) + * - 生产环境: https://asset.lilywhite.cc/character/1.png (直接请求) + * + * 注意: + * - 此代理仅在开发环境 (npm run dev) 生效 + * - 生产环境构建后,使用完整的原始 URL + * - 使用此工具函数可以自动处理环境差异 + */ + +/** + * 将完整的 asset.lilywhite.cc URL 转换为适合当前环境的 URL + * + * @param url - 原始 URL,例如:https://asset.lilywhite.cc/thvote/imgs/nav/character@100px.png + * @returns 适合当前环境的 URL + * + * @example + * // 开发环境 + * getAssetUrl('https://asset.lilywhite.cc/thvote/imgs/nav/character@100px.png') + * // 返回: '/th-assets/thvote/imgs/nav/character@100px.png' + * + * // 生产环境 + * getAssetUrl('https://asset.lilywhite.cc/thvote/imgs/nav/character@100px.png') + * // 返回: 'https://asset.lilywhite.cc/thvote/imgs/nav/character@100px.png' + */ +export function getAssetUrl(url: string): string { + // 如果不是 asset.lilywhite.cc 的 URL,直接返回 + if (!url.includes('asset.lilywhite.cc')) { + return url + } + + // 开发环境:使用代理路径 + if (import.meta.env.DEV) { + // 将 https://asset.lilywhite.cc 替换为 /th-assets + return url.replace('https://asset.lilywhite.cc', '/th-assets') + } + + // 生产环境:直接使用原始 URL + return url +} + +/** + * 批量处理资源 URL + * + * @param urls - URL 数组 + * @returns 处理后的 URL 数组 + */ +export function getAssetUrls(urls: string[]): string[] { + return urls.map(getAssetUrl) +} \ No newline at end of file diff --git a/packages/vote/src/common/lib/characterSearch.ts b/packages/vote/src/common/lib/characterSearch.ts new file mode 100644 index 0000000..944722a --- /dev/null +++ b/packages/vote/src/common/lib/characterSearch.ts @@ -0,0 +1,75 @@ +import { CachedSearcher, SearchLogicContain } from 'pinin' +import { pinin } from './pinin' + +export type SearchableCharacterLike = { + name: string + altnames: string[] + work: string[] +} + +export function createCharacterSearcher(list: T[]): CachedSearcher { + const s = new CachedSearcher(SearchLogicContain, pinin) + + for (const c of list) { + s.put(c.name.toLowerCase(), c) + for (const altname of c.altnames) { + s.put(altname.toLowerCase(), c) + } + for (const work of c.work) { + s.put(work.toLowerCase(), c) + } + } + + return s +} + +export const orderOptions = [ + { + name: '出场正序', + value: 'newest', + }, + { + name: '出场倒序', + value: 'oldest', + }, +] + +export function sortCharactersByOrder(list: T[], orderName: string): T[] { + if (orderName === orderOptions[0].name) { + list.sort((a, b) => a.date - b.date) + } else { + list.sort((a, b) => b.date - a.date) + } + return list +} + +export function searchAndSort( + list: T[], + keyword: string, + orderName: string +): T[] { + // 逻辑修改内容: 判断是否有关键词,没有则直接排序并返回,避免索引构建带来的性能开销 + if (!keyword || !keyword.trim()) { + return sortCharactersByOrder([...list], orderName) + } + + // 只有在确实需要搜索时才构建 Searcher + const searcher = createCharacterSearcher(list) + const res = [...new Set(searcher.search(keyword.toLowerCase()))] + + return sortCharactersByOrder(res, orderName) +} +export function filterCharactersByMeta( + list: T[], + kinds?: string[], + workName?: string +): T[] { + let result = list + if (kinds && kinds.length) { + result = result.filter((chara) => chara.kind.some((k) => kinds.includes(k))) + } + if (workName) { + result = result.filter((chara) => chara.work.some((w) => w === workName)) + } + return result +} diff --git a/packages/vote/src/common/lib/exportVoteData.ts b/packages/vote/src/common/lib/exportVoteData.ts new file mode 100644 index 0000000..cfc4a65 --- /dev/null +++ b/packages/vote/src/common/lib/exportVoteData.ts @@ -0,0 +1,62 @@ +/** + * 临时投票数据导出工具 + * + * 注意:这是临时实现,数据从本地 localStorage 和现有状态管理中读取 + * 待后端 API 完成后,应该替换为真实的 GraphQL 查询 + * + * 预期后端 API: + * query GetMyVoteData($voteToken: String!) { + * getMyVoteData(voteToken: $voteToken) { + * characters { id name first reason } + * music { id name first reason } + * couples { idA idB idC active first reason } + * doujins { id name circle reason } + * questionnaire { ... } + * } + * } + */ + +import { characters, characterHonmei } from '@/vote-character/lib/voteData' +import { charactersVoted } from '@/vote-character/lib/characterList' +import { characterList } from '@/vote-character/lib/characterList' +import { Character } from '@/vote-character/lib/character' +import { voteToken } from '@/home/lib/user' + +/** + * 获取角色投票数据用于导出 + * + * 只返回 id + reason + honmei + * 注意:非本命角色不需要 reason(可选),只有本命角色需要填写 + * 其他信息(name, color, date, work, image)从 characterList 中读取 + */ +export function getExportCharacterData() { + // 获取已投票的角色(排除空票) + const votedCharacters = charactersVoted.value.filter(char => char.id !== '0') + + // 只返回 id + reason + honmei + // 非本命角色 reason 可以为空 + return votedCharacters.map(char => ({ + id: char.id, + isHonmei: char.honmei, + reason: char.honmei ? (char.reason || '') : '' + })) +} + +/** + * 模拟异步获取投票数据(待替换为真实 API) + */ +export async function exportVoteData() { + // 模拟网络延迟 + await new Promise(resolve => setTimeout(resolve, 500)) + + return { + voteToken: voteToken.value, + exportTime: new Date().toISOString(), + characters: getExportCharacterData(), + // TODO: 添加其他投票类型 + // music: [], + // couples: [], + // doujins: [], + // questionnaire: {} + } +} \ No newline at end of file diff --git a/packages/vote/src/common/lib/testHelper.ts b/packages/vote/src/common/lib/testHelper.ts new file mode 100644 index 0000000..6352a31 --- /dev/null +++ b/packages/vote/src/common/lib/testHelper.ts @@ -0,0 +1,205 @@ +/** + * 测试环境辅助工具 + * + * 注意:仅用于开发和测试环境,生产环境不要使用 + * + * 使用方式: + * 1. 在浏览器控制台中调用 setupTestUser() + * 2. 或者直接访问 /test 页面使用测试工具 + */ + +import { voteToken, isLogin, setUserDataToLocalStorage, user, createDefaultVoter, enableDevMode, disableDevMode } from '@/home/lib/user' +import { characters } from '@/vote-character/lib/voteData' +import { characterList } from '@/vote-character/lib/characterList' +import { Character } from '@/vote-character/lib/character' + +/** + * 模拟登录用户 + */ +export function setupTestUser() { + console.log('🔧 设置测试用户...') + + // 启用开发模式以绕过后端验证 + enableDevMode() + + // 模拟用户数据 + const testUser = { + ...createDefaultVoter(), + username: '测试用户', + phone: '138****8888', + email: 'test@example.com', + createdAt: new Date('2024-01-01'), + } + + // 模拟 token(在开发模式下不需要后端验证) + const testVoteToken = 'test_token_' + Date.now() + const testSessionToken = 'test_session_' + Date.now() + + // 保存到 localStorage + user.value = testUser + setUserDataToLocalStorage(testUser, testVoteToken, testSessionToken) + + console.log('✅ 测试用户设置成功') + console.log('用户名:', testUser.username) + console.log('Token:', testVoteToken) + console.log('登录状态:', isLogin.value ? '已登录' : '未登录') + + return { + user: testUser, + voteToken: testVoteToken, + isLogin: isLogin.value + } +} + +/** + * 设置角色投票数据 + * 只存储 id + reason + honmei,其他信息从 characterList 中读取 + * @param honmeiName 本命角色名称 + * @param otherNames 其他角色名称数组 + */ +export function setupTestCharacterVotes(honmeiName?: string, otherNames: string[] = []) { + console.log('🔧 设置测试角色投票数据...') + + // 清空现有数据(使用 Character 类) + characters.value = new Array(8).fill(null).map(() => new Character()) + + // 设置本命角色 + if (honmeiName) { + const honmeiChar = characterList.find(c => c.name.includes(honmeiName)) + if (honmeiChar) { + const newHonmei = new Character() + newHonmei.id = honmeiChar.id + newHonmei.honmei = true + newHonmei.reason = `奶龙奶龙奶龙奶龙奶龙奶龙奶龙奶龙奶龙奶龙奶龙我是奶龙!` + characters.value[0] = newHonmei + console.log(`✅ 设置本命角色: ${honmeiChar.name} (ID: ${honmeiChar.id})`) + } + } + + // 设置其他角色 + otherNames.forEach((name, index) => { + if (index >= 7) return // 最多7个普通角色 + const char = characterList.find(c => c.name.includes(name)) + if (char) { + const newChar = new Character() + newChar.id = char.id + newChar.honmei = false + newChar.reason = '' // 非本命角色不需要 reason + characters.value[index + 1] = newChar + console.log(`✅ 设置角色 ${index + 1}: ${char.name} (ID: ${char.id})`) + } + }) + + // 保存到 localStorage + localStorage.setItem('characters', JSON.stringify(characters.value)) + + console.log('✅ 角色投票数据设置完成(只存储 id + reason + honmei)') +} + +/** + * 快速设置常见角色投票数据 + */ +export function setupQuickTestVotes() { + console.log('🔧 设置快速测试数据...') + + // 模拟登录 + setupTestUser() + + // 设置角色投票(博丽灵梦 + 常见角色) + setupTestCharacterVotes( + '博丽灵梦', // 本命 + ['雾雨魔理沙', '琪露诺', '十六夜咲夜', '蕾米莉亚', '芙兰朵露', '帕秋莉', '爱丽丝'] // 其他7个 + ) + + console.log('✅ 快速测试数据设置完成!') + console.log('💡 现在可以在首页点击头像,使用"导出角色投票为图片"功能了') +} + +/** + * 获取可用的角色列表(用于测试) + */ +export function getAvailableCharacters() { + const commonCharacters = characterList + .filter(c => c.name.includes('灵梦') || c.name.includes('魔理沙') || c.name.includes('琪露诺')) + .slice(0, 20) + + console.log('📋 可用角色列表(前20个):') + commonCharacters.forEach((char, index) => { + console.log(`${index + 1}. ${char.name} (ID: ${char.id})`) + }) + + return commonCharacters +} + +/** + * 清理所有测试数据 + */ +export function clearTestUserData() { + console.log('🧹 清理测试数据...') + + // 禁用开发模式 + disableDevMode() + + localStorage.removeItem('user') + localStorage.removeItem('voteToken') + localStorage.removeItem('sessionToken') + localStorage.removeItem('characters') + localStorage.removeItem('musics') + localStorage.removeItem('couples') + localStorage.removeItem('doujins') + localStorage.removeItem('questionnaireDataLocal') + + // 重置状态 + user.value = createDefaultVoter() + voteToken.value = '' + + // 刷新页面 + location.reload() +} + +/** + * 检查当前登录状态 + */ +export function checkTestStatus() { + console.log('📊 当前测试状态:') + console.log('登录状态:', isLogin.value ? '✅ 已登录' : '❌ 未登录') + console.log('Token:', voteToken.value || '(空)') + console.log('用户名:', user.value.username || '(未设置)') + + const savedCharacters = JSON.parse(localStorage.getItem('characters') || '[]') + const validCharacters = savedCharacters.filter((c: any) => c.id !== '0') + console.log('角色投票数量:', validCharacters.length) + if (validCharacters.length > 0) { + console.log('已投票角色:', validCharacters.map((c: any) => c.name)) + } +} + +// 在控制台暴露全局函数(仅在开发环境) +if (import.meta.env.DEV) { + ;(window as any).testHelper = { + setupTestUser, + setupTestCharacterVotes, + setupQuickTestVotes, + getAvailableCharacters, + clearTestUserData, + checkTestStatus + } + + console.log(` +╔═══════════════════════════════════════════════════════════╗ +║ 测试环境辅助工具已加载 ✅ ║ +╚═══════════════════════════════════════════════════════════╝ + +💡 在控制台使用以下命令: + + testHelper.setupQuickTestVotes() - 快速设置完整测试数据 + testHelper.setupTestUser() - 仅设置测试用户 + testHelper.setupTestCharacterVotes('灵梦', ['魔理沙', '琪露诺']) + - 自定义角色投票 + testHelper.getAvailableCharacters() - 查看可用角色 + testHelper.checkTestStatus() - 检查当前状态 + testHelper.clearTestUserData() - 清理测试数据 + +🎯 快速开始: testHelper.setupQuickTestVotes() + `) +} \ No newline at end of file diff --git a/packages/vote/src/graphql/codegen.yml b/packages/vote/src/graphql/codegen.yml index fc8615f..191535b 100644 --- a/packages/vote/src/graphql/codegen.yml +++ b/packages/vote/src/graphql/codegen.yml @@ -1,7 +1,7 @@ overwrite: true schema: # - https://touhou.vote/v10-be/graphql - - https://touhou.ai/vote-be/graphql + - http://34.86.25.42/graphql generates: src/graphql/__generated__/graphql.ts: plugins: diff --git a/packages/vote/src/home/UserHome.vue b/packages/vote/src/home/UserHome.vue index 144ddc1..daae051 100644 --- a/packages/vote/src/home/UserHome.vue +++ b/packages/vote/src/home/UserHome.vue @@ -38,6 +38,9 @@ >
账号设置
+
+ +
账号设置
+
+ +
('') export const sessionToken = ref('') +/** + * 开发模式标志 + * 仅用于开发和测试环境,允许使用测试数据绕过后端验证 + */ +export const isDevMode = ref(false) + export const isLogin = computed(() => voteToken.value != '') +/** + * 启用开发模式 + * 警告:仅用于开发和测试,生产环境不要使用 + */ +export function enableDevMode() { + if (import.meta.env.DEV) { + isDevMode.value = true + console.warn('⚠️ 开发模式已启用 - 绕过后端验证') + } else { + console.error('❌ 无法在生产环境启用开发模式') + } +} + +/** + * 禁用开发模式 + */ +export function disableDevMode() { + isDevMode.value = false + console.log('✅ 开发模式已禁用') +} + export const voteCharacterComplete = ref(false) export const voteMusicComplete = ref(false) export const voteCoupleComplete = ref(false) @@ -71,6 +98,10 @@ export function setUserDataToLocalStorage(user: Voter, token: string, session: s localStorage.setItem('user', JSON.stringify(user)) localStorage.setItem('voteToken', token) localStorage.setItem('sessionToken', session) + + // 同时更新响应式变量 + voteToken.value = token + sessionToken.value = session } function replacePhoneNum(phone: string): string { let replacedPhoneNum = '' @@ -97,7 +128,7 @@ export function deleteUserData(): void { localStorage.removeItem('sessionToken') localStorage.removeItem('questionnaireDataLocal') localStorage.removeItem('characters') - localStorage.removeItem('muiscs') + localStorage.removeItem('musics') localStorage.removeItem('couples') localStorage.removeItem('doujins') localStorage.removeItem('confirmedDoujinNotice') @@ -111,6 +142,14 @@ export async function checkLoginStatus(needGetUserDataFromLocalStorage = false): deleteUserData() return } + + // 开发模式下,跳过后端验证,直接使用本地数据 + if (isDevMode.value) { + console.log('🔧 开发模式:跳过后端验证,使用本地数据') + getUserDataFromLocalStorage() + return + } + await fetch('/v11-be/user-token-status', { method: 'POST', headers: new Headers({ @@ -139,7 +178,9 @@ export async function checkLoginStatus(needGetUserDataFromLocalStorage = false): }) .catch((err) => { console.log(err) - if (err.graphQLErrors[0].extensions.error_kind === 'REQUEST_TOO_FREQUENT') popMessageText('请求过于频繁!') + if (err.graphQLErrors && err.graphQLErrors[0]?.extensions?.error_kind === 'REQUEST_TOO_FREQUENT') { + popMessageText('请求过于频繁!') + } deleteUserData() }) } diff --git a/packages/vote/src/main/main.ts b/packages/vote/src/main/main.ts index 74472db..660b2c0 100644 --- a/packages/vote/src/main/main.ts +++ b/packages/vote/src/main/main.ts @@ -12,6 +12,11 @@ import 'nprogress/css/nprogress.css' import '@/tailwindcss' import '@/darkmode' +// 在开发环境加载测试工具 +if (import.meta.env.DEV) { + import('@/common/lib/testHelper') +} + // start progress bar function incProcess() { if (NProgress.isStarted()) NProgress.inc() @@ -86,7 +91,7 @@ const router = createRouter({ }, ], }) -let pendingNProgress: number | undefined +let pendingNProgress: ReturnType | undefined router.beforeEach(async (to, from, next) => { if (pendingNProgress === undefined) pendingNProgress = setTimeout(() => { diff --git a/packages/vote/src/vote-character/lib/characterList.ts b/packages/vote/src/vote-character/lib/characterList.ts index 1905186..3257a15 100644 --- a/packages/vote/src/vote-character/lib/characterList.ts +++ b/packages/vote/src/vote-character/lib/characterList.ts @@ -1,11 +1,10 @@ import { computed, ref } from 'vue' -import { CachedSearcher, SearchLogicContain } from 'pinin' import type { Character } from './character' import { character0 } from './character' import { characterHonmei, characters } from './voteData' import { filterForKind, workSelected } from './workList' -import { pinin } from '@/common/lib/pinin' import { characterList } from '@touhou-vote/shared/data/character' +import { orderOptions as sharedOrderOptions, filterCharactersByMeta, searchAndSort } from '@/common/lib/characterSearch' export { characterList } @@ -18,13 +17,8 @@ export const characterListLeft = computed(() => { return character.id != characterHonmei.value.id && !characterInCharacters }) - if (filterForKind.value.length) { - charaList = charaList.filter((chara) => filterForKind.value.find((k1) => chara.kind.find((k2) => k2 === k1.value))) - } - - if (workSelected.value.name) { - charaList = charaList.filter((chara) => chara.work.find((work) => work === workSelected.value.name)) - } + const kinds = filterForKind.value.map((k) => k.value) + charaList = filterCharactersByMeta(charaList, kinds, workSelected.value.name || undefined) return charaList }) @@ -36,42 +30,9 @@ export const charactersVotedWithoutHonmei = computed(() => { return charactersVoted.value.filter((chara) => !chara.honmei) }) -export const orderOptions = [ - { - name: '出场正序', - value: 'newest', - }, - { - name: '出场倒序', - value: 'oldest', - }, -] +export const orderOptions = sharedOrderOptions export const order = ref(orderOptions[0]) export const keyword = ref('') - -const searcher = computed(() => { - const s = new CachedSearcher(SearchLogicContain, pinin) - - for (const c of characterListLeft.value) { - s.put(c.name.toLowerCase(), c) - for (const altname of c.altnames) { - s.put(altname.toLowerCase(), c) - } - for (const work of c.work) { - s.put(work.toLowerCase(), c) - } - } - - return s -}) export const characterListLeftWithFilter = computed(() => { - const res = keyword.value ? [...new Set(searcher.value.search(keyword.value.toLowerCase()))] : characterListLeft.value - - if (order.value.name === orderOptions[0].name) { - res.sort((a, b) => a.date - b.date) - } else { - res.sort((a, b) => b.date - a.date) - } - - return res + return searchAndSort(characterListLeft.value, keyword.value, order.value.name) }) diff --git a/packages/vote/src/vote-couple/components/CharacterSelect.vue b/packages/vote/src/vote-couple/components/CharacterSelect.vue index 65ad800..fa6afa0 100644 --- a/packages/vote/src/vote-couple/components/CharacterSelect.vue +++ b/packages/vote/src/vote-couple/components/CharacterSelect.vue @@ -55,7 +55,6 @@ import type { PropType } from 'vue' import { computed, ref, watchEffect } from 'vue' import { useVModels, watchThrottled } from '@vueuse/core' -import { CachedSearcher, SearchLogicContain } from 'pinin' import AdvancedFilter from './AdvancedFilter.vue' import VoteSelect from '@/common/components/VoteSelect.vue' import characterImages from '@/vote-character/assets/defaultCharacterImage.png?url' @@ -63,7 +62,7 @@ import { Character } from '@/vote-character/lib/character' import { characterList } from '@/vote-character/lib/characterList' import { Couple } from '@/vote-couple/lib/couple' import { filterForKind, workSelected } from '@/vote-couple/lib/workList' -import { pinin } from '@/common/lib/pinin' +import { orderOptions, filterCharactersByMeta, searchAndSort } from '@/common/lib/characterSearch' import Mask from '@/common/components/Mask.vue' const props = defineProps({ @@ -110,16 +109,6 @@ watchEffect(() => { const loading = ref(false) const advancedFilterOpen = ref(false) -const orderOptions = [ - { - name: '出场正序', - value: 'newest', - }, - { - name: '出场倒序', - value: 'oldest', - }, -] const order = ref(orderOptions[0]) const characterListLeft = computed(() => { @@ -133,13 +122,8 @@ const characterListLeft = computed(() => { // return !characterInCharacters // }) - if (filterForKind.value.length) { - charaList = charaList.filter((chara) => filterForKind.value.find((k1) => chara.kind.find((k2) => k2 === k1.value))) - } - - if (workSelected.value.name) { - charaList = charaList.filter((chara) => chara.work.find((work) => work === workSelected.value.name)) - } + const kinds = filterForKind.value.map((k) => k.value) + charaList = filterCharactersByMeta(charaList, kinds, workSelected.value.name || undefined) return charaList }) @@ -149,31 +133,9 @@ function search(): void { keyword.value = searchContent.value } watchThrottled(searchContent, search, { throttle: 100 }) -const searcher = computed(() => { - const s = new CachedSearcher(SearchLogicContain, pinin) - - for (const c of characterListLeft.value) { - s.put(c.name.toLowerCase(), c) - for (const altname of c.altnames) { - s.put(altname.toLowerCase(), c) - } - for (const work of c.work) { - s.put(work.toLowerCase(), c) - } - } - return s -}) const characterListLeftWithFilter = computed(() => { - const res = keyword.value ? [...new Set(searcher.value.search(keyword.value.toLowerCase()))] : characterListLeft.value - - if (order.value.name === orderOptions[0].name) { - res.sort((a, b) => a.date - b.date) - } else { - res.sort((a, b) => b.date - a.date) - } - - return res + return searchAndSort(characterListLeft.value, keyword.value, order.value.name) }) function characterSelect(id: string): void { diff --git a/packages/vote/src/vote-music/lib/voteData.ts b/packages/vote/src/vote-music/lib/voteData.ts index b960130..e129baa 100644 --- a/packages/vote/src/vote-music/lib/voteData.ts +++ b/packages/vote/src/vote-music/lib/voteData.ts @@ -14,11 +14,11 @@ export const musics = ref(new Array(MUSICVOTENUM).fill(null).map(() => watch(musics, setVoteDataMusics, { deep: true }) function setVoteDataMusics(): void { - localStorage.setItem('muiscs', JSON.stringify(musics.value)) + localStorage.setItem('musics', JSON.stringify(musics.value)) } export function updateVoteMusics(newVoteData: MusicSubmitQuery[]): void { - const musicsDataLocal: Music[] = JSON.parse(localStorage.getItem('muiscs') || '[]') + const musicsDataLocal: Music[] = JSON.parse(localStorage.getItem('musics') || '[]') if (JSON.stringify(musicsDataLocal) != '[]') { musics.value = musicsDataLocal } else if (newVoteData.length) { diff --git a/packages/vote/vite.config.ts b/packages/vote/vite.config.ts index 8a00e6b..0c69a1d 100644 --- a/packages/vote/vite.config.ts +++ b/packages/vote/vite.config.ts @@ -12,18 +12,20 @@ import yaml from '@rollup/plugin-yaml' /** * Vite Configuration File - * * Docs: https://vitejs.dev/config/ */ -export default defineConfig(async ({ command, mode }) => { - /* create __generated__ dir */ - { - const list = ['dts'] - const promises = [] - for (const dir of list) promises.push(fsp.mkdir(resolve(__dirname, `./src/${dir}/__generated__`))) - await Promise.allSettled(promises) +// 自动创建生成目录逻辑(同步实现) +const list = ['dts'] +for (const dir of list) { + try { + // 使用同步方法确保目录存在 + require('fs').mkdirSync(resolve(__dirname, `./src/${dir}/__generated__`), { recursive: true }) + } catch (e) { + // 忽略目录已存在的错误 } +} +export default defineConfig(({ command, mode }) => { return { optimizeDeps: { exclude: ['@touhou-vote/shared'], @@ -48,22 +50,45 @@ export default defineConfig(async ({ command, mode }) => { dts: resolve(__dirname, './src/dts/__generated__/viteComponents.d.ts'), }), icons(), - { - ...visualizer({ - filename: 'dist/stats.html', - gzipSize: true, - brotliSize: true, - }), - apply: 'build', - }, - ], + // 仅在构建时启用分析 + command === 'build' ? visualizer({ + filename: 'dist/stats.html', + gzipSize: true, + brotliSize: true, + }) : undefined, + ].filter(Boolean), + server: { proxy: { + // 现有的后端接口代理 '/v11-be': { target: 'https://touhou.ai/vote-be', changeOrigin: true, secure: false, - rewrite: (path) => path.replace(/^\/v11-be/, ''), + rewrite: (path: string) => path.replace(/^\/v11-be/, ''), + }, + + // 重点:解决东方云盘图片跨域的代理 + '/th-assets': { + target: 'https://asset.lilywhite.cc', + changeOrigin: true, + secure: false, // 如果目标站是自签名证书或有SSL问题,设为 false + rewrite: (path: string) => path.replace(/^\/th-assets/, ''), + + // 核心配置:修改响应头 + configure: (proxy) => { + proxy.on('proxyRes', (proxyRes) => { + // 1. 强行添加 CORS 允许头 + proxyRes.headers['Access-Control-Allow-Origin'] = '*'; + proxyRes.headers['Access-Control-Allow-Methods'] = 'GET, OPTIONS'; + proxyRes.headers['Access-Control-Allow-Headers'] = 'X-Requested-With, content-type, Authorization'; + + // 2. 优化图片缓存控制(可选) + if (proxyRes.headers['content-type']?.includes('image')) { + proxyRes.headers['cache-control'] = 'public, max-age=31536000'; + } + }); + } }, }, }, @@ -75,4 +100,4 @@ export default defineConfig(async ({ command, mode }) => { charset: 'utf8', }, } -}) +}) \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18c5691..8e72470 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,7 +242,7 @@ importers: packages/vote: dependencies: '@apollo/client': - specifier: ^3.7.12 + specifier: ^3.11.8 version: 3.11.8(graphql-ws@5.12.1(graphql@16.9.0))(graphql@16.9.0) '@touhou-vote/shared': specifier: workspace:* @@ -263,8 +263,11 @@ importers: specifier: ^2.0.4 version: 2.0.4 graphql: - specifier: ^16.8.1 + specifier: ^16.9.0 version: 16.9.0 + html2canvas: + specifier: ^1.4.1 + version: 1.4.1 nprogress: specifier: 1.0.0-1 version: 1.0.0-1 @@ -1766,6 +1769,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1955,6 +1962,9 @@ packages: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + css-render@0.15.14: resolution: {integrity: sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==} @@ -2690,6 +2700,10 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + http-cache-semantics@4.1.1: resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} @@ -3889,6 +3903,9 @@ packages: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -4125,6 +4142,9 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + value-or-promise@1.0.12: resolution: {integrity: sha512-Z6Uz+TYwEqE7ZN50gwn+1LCVo9ZVrpxRPOhOLnncYkY1ZzOYtrX8Fwf/rFktZ8R5mJms6EZf5TqNOMeZmnPq9Q==} engines: {node: '>=12'} @@ -6253,6 +6273,8 @@ snapshots: balanced-match@1.0.2: {} + base64-arraybuffer@1.0.2: {} + base64-js@1.5.1: {} binary-extensions@2.3.0: {} @@ -6491,6 +6513,10 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + css-render@0.15.14: dependencies: '@emotion/hash': 0.8.0 @@ -7404,6 +7430,11 @@ snapshots: dependencies: react-is: 16.13.1 + html2canvas@1.4.1: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + http-cache-semantics@4.1.1: {} http-proxy-agent@5.0.0: @@ -8597,6 +8628,10 @@ snapshots: mkdirp: 1.0.4 yallist: 4.0.0 + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + text-table@0.2.0: {} throttle-debounce@3.0.1: {} @@ -8867,6 +8902,10 @@ snapshots: util-deprecate@1.0.2: {} + utrie@1.0.2: + dependencies: + base64-arraybuffer: 1.0.2 + value-or-promise@1.0.12: {} vdirs@0.1.8(vue@3.5.12(typescript@5.6.3)): From 8115414898d6ef6975b4ddb5fb1e28ef409e1c56 Mon Sep 17 00:00:00 2001 From: Renko_0ac9 <1471850534@qq.com> Date: Sun, 15 Feb 2026 02:08:58 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(vote):=20:sparkles:=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=8A=95=E7=A5=A8=E5=AF=BC=E5=87=BA=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E8=A7=92=E8=89=B2?= =?UTF-8?q?/CP/=E9=9F=B3=E4=B9=90=E4=B8=89=E4=B8=AA=E6=8A=95=E7=A5=A8?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增三个投票导出组件,将用户投票数据生成精美的图片卡片: - ExportCharacterVoteImage.vue - 角色投票导出 * 展示本命角色大卡片和其他角色网格布局 * 支持角色头像、姓名、作品、理由等信息展示 * 动态生成主题色背景和装饰效果 - ExportCoupleVoteImage.vue - CP投票导出 * 支持2-3人CP组合展示 * 显示主动方标记(下部弓形区域) * 本命CP和其他CP分层展示,每张卡片独立主题色 - ExportMusicVoteImage.vue - 音乐投票导出 * 展示本命音乐和其他音乐投票 * 显示曲目名称、原名、专辑等信息 * 基于曲目名称哈希自动生成主题色 技术特性: - 使用 html2canvas 进行离屏DOM渲染,生成高质量PNG图片 - 支持预览、下载、分享三种操作 - 兼容 Web Share API(移动端) - 自动等待图片加载完成后再生成 - 支持开发环境CDN代理 数据层设计: - exportVoteData.ts:提供统一数据导出接口 * 支持 localStorage 和 GraphQL 双数据源 * 实现数据映射:完整数据精简为 {id, isHonmei, reason} * 智能回退机制:GraphQL失败或数据不完整时自动切换到本地数据 * 严格校验:确保本命角色/CP/音乐存在 - voteDataSource.ts:统一数据访问层 * 抽象数据源模式:'local' | 'graphql' | 'auto' * auto模式优先GraphQL,失败时回退到localStorage * 提供错误分类识别:网络错误、认证错误、服务器错误 * 统一GraphQL查询定义和结果映射 * 支持五大投票类型:character/music/couple/doujin/questionnaire - testHelper.ts:测试环境辅助工具 * 提供 setupAllTestVotes() 一键设置完整测试数据 * 支持自定义测试数据设置(角色/CP/音乐) * 集成数据源模式控制 * 提供便捷的控制台API * 支持状态检查和数据清理 背景说明: - 由于后端GraphQL API尚未完成,实现了基于localStorage的假数据测试 - 通过抽象数据访问层,实现了兼容GraphQL和本地数据的统一接口 - 后端API就绪后,只需将数据源模式切换为'graphql'即可无缝迁移 - 当前默认使用'auto'模式,优先GraphQL,失败时自动降级到本地数据 测试方式: 1. pnpm dev 启动开发环境 2. 打开浏览器控制台,执行 testHelper.setupAllTestVotes() 3. 访问投票页面,点击"导出为图片"按钮 4. 查看生成的投票卡片,测试下载和分享功能 5. 使用 testHelper.setDataSourceMode('graphql') 测试GraphQL模式 --- doc/260215EXPORT_FEATURE_UPDATE.md | 358 +++++++++++++ .../components/ExportCharacterVoteImage.vue | 102 +++- .../components/ExportCoupleVoteImage.vue | 475 ++++++++++++++++++ .../components/ExportMusicVoteImage.vue | 382 ++++++++++++++ .../vote/src/common/lib/exportVoteData.ts | 281 +++++++++-- .../vote/src/common/lib/testErrorHandling.ts | 170 +++++++ packages/vote/src/common/lib/testHelper.ts | 344 ++++++++++++- .../vote/src/common/lib/voteDataSource.ts | 306 +++++++++++ packages/vote/src/home/UserHome.vue | 14 + packages/vote/src/main/main.ts | 1 + packages/vote/vite.config.ts | 18 + 11 files changed, 2400 insertions(+), 51 deletions(-) create mode 100644 doc/260215EXPORT_FEATURE_UPDATE.md create mode 100644 packages/vote/src/common/components/ExportCoupleVoteImage.vue create mode 100644 packages/vote/src/common/components/ExportMusicVoteImage.vue create mode 100644 packages/vote/src/common/lib/testErrorHandling.ts create mode 100644 packages/vote/src/common/lib/voteDataSource.ts diff --git a/doc/260215EXPORT_FEATURE_UPDATE.md b/doc/260215EXPORT_FEATURE_UPDATE.md new file mode 100644 index 0000000..2eafc20 --- /dev/null +++ b/doc/260215EXPORT_FEATURE_UPDATE.md @@ -0,0 +1,358 @@ +# 投票导出更新-20260215 + +## 更新概述 + +本次更新为投票系统添加了**投票导出为图片**功能,支持角色、CP、音乐类型(其它类型尚未设计风格故没加)。用户可以将自己的投票数据生成精美图片以分享和保存。 + +可以在用户头像点击后的下拉栏找到三个导出选项的按钮(之后它们具体放到哪要看UI设计了) + +由于后端老哥还没写完最终版本,我需要自己测试,因此我写了一些ts,基于localStorage设置数据(包括登录信息和投票)结果,并且设计了一个兼容GraphQL和本地数据的统一接口,后端API就绪后只需要切换数据源模式即可无缝迁移。 + +在最下面是一个todo list,请看。 + +## 核心功能 + +### 1. 新增三个导出组件 + +#### ExportCharacterVoteImage.vue - 角色投票导出 +- **本命角色大卡片**:展示本命角色的头像、姓名、作品、理由 +- **其他角色网格**:3列网格布局展示其他投票角色 +- **主题色**:基于角色颜色自动生成背景色 + +#### ExportCoupleVoteImage.vue - CP投票导出 +- **本命CP大卡片**:展示2-3人CP组合 +- **主动方标记**:底部弓形区域显示主动方角色 +- **其他CP卡片**:纵向排列,每张卡片独立主题色 + +#### ExportMusicVoteImage.vue - 音乐投票导出 +- **本命音乐大卡片**:展示曲目名称、原名、专辑、理由 +- **其他音乐网格**:3列网格布局展示其他音乐投票 +- **自动主题色**:基于曲目名称哈希生成独特颜色 +- **完整信息**:包含中文名、原名、专辑等元数据 + +### 2. 图片生成与交互 + +**技术实现** +- 使用 `html2canvas` 进行离屏渲染 +- 自动等待图片加载完成 +- 对于外站图片资源,由于CORS限制,开发环境通过Vite的代理加载,生产环境需确保资源支持跨域访问(因为把图片画到Canvas上面比把图片摆出来更严格) + +**用户操作** +- **预览**:在对话框中预览生成的图片 +- **下载**:保存为PNG文件到本地 +- **分享**:支持Web Share API(浏览器默认的) + +### 3. 数据层架构 + +因为我们的后端目前还没有改和确认上线,为了测试方便,我加入了使用localStorage的假数据(从登录状态、用户信息,到投票结果都是假的),并且设计了一个兼容GraphQL和本地数据的统一接口,后端API就绪后只需要切换数据源模式即可无缝迁移。 + +#### exportVoteData.ts - 投票数据导出工具 + +提供统一的数据导出接口: + +```typescript +// 角色投票数据 +getExportCharacterDataFromDataSource(mode?: DataSourceMode) + +// CP投票数据 +getExportCoupleDataFromDataSource(mode?: DataSourceMode) + +// 音乐投票数据 +getExportMusicDataFromDataSource(mode?: DataSourceMode) +``` + +**特性** +- **数据精简**:只导出 `{id, isHonmei, reason}`,其他信息从列表读取 +- **智能回退**:GraphQL失败时自动切换到localStorage +- **数据校验**:确保本命角色/CP/音乐存在 + +#### voteDataSource.ts - 统一数据访问层 + +```typescript +// 数据源模式类型 +type DataSourceMode = 'local' | 'graphql' | 'auto' + +// 设置数据源模式 +setDataSourceMode(mode: DataSourceMode) + +// 获取投票数据 +fetchVoteData(dataType: VoteDataType, forceMode?: DataSourceMode) +``` + +**模式说明** +- **`local`**:强制使用localStorage数据 +- **`graphql`**:强制使用GraphQL API +- **`auto`**:优先GraphQL,失败时回退到localStorage + +**错误识别** +- 网络连接失败 +- 认证/token无效 +- 服务器错误 +- 数据不完整 + +### 4. 测试工具 + +#### testHelper.ts - 开发测试辅助 + +提供便捷的测试数据设置: + +```javascript +// 在浏览器控制台使用 + +// 一键设置完整测试数据 +testHelper.setupAllTestVotes() + +// 快速设置角色投票 +testHelper.setupQuickTestVotes() + +// 快速设置CP投票 +testHelper.setupQuickTestCoupleVotes() + +// 快速设置音乐投票 +testHelper.setupQuickTestMusicVotes() + +// 自定义测试数据 +testHelper.setupTestCharacterVotes('博丽灵梦', ['雾雨魔理沙', '琪露诺']) +testHelper.setupTestCoupleVotes( + [{names: ['灵梦', '魔理沙'], active: '灵梦', reason: '理由'}] +) +testHelper.setupTestMusicVotes('幽雅地绽放吧,墨染的樱花', ['红魔', '月']) + +// 检查当前状态 +testHelper.checkTestStatus() + +// 清理测试数据 +testHelper.clearTestUserData() + +// 设置数据源模式 +testHelper.setDataSourceMode('local') // 或 'graphql', 'auto' +``` + +## 技术亮点 + +### 1. GraphQL与本地数据兼容 + +- **统一接口**:通过 `voteDataSource.ts` 抽象数据访问层 +- **智能降级**:后端未完成时,自动使用localStorage数据 +- **透明切换**:用户无需感知数据源变化 +- **未来扩展**:后端API就绪后无缝切换 + +### 2. 数据映射策略 + +**导出数据格式**: +```typescript +// 角色投票 +{ + id: string // 角色ID + isHonmei: boolean // 是否本命 + reason: string // 理由(仅本命需要) +} + +// CP投票 +{ + idA: string // 第一个角色ID + idB: string // 第二个角色ID + idC: string // 第三个角色ID(可选) + active: string // 主动方角色ID + isHonmei: boolean // 是否本命 + reason: string // 理由 +} + +// 音乐投票 +{ + id: string // 音乐ID + isHonmei: boolean // 是否本命 + reason: string // 理由(仅本命需要) +} +``` + +**完整信息获取**: +- 从 `characterList`/`musicList` 读取详细信息 +- 避免数据冗余存储 +- 保持数据一致性 + +### 3. 图片生成优化 + +**离屏渲染** +- 使用绝对定位隐藏DOM(`left: -9999px`) + +**性能和加载** +- 等待图片加载完成后再生成 +- 2x scale提高图片清晰度 + +### 4. 错误处理与用户体验 + +**错误识别** +``` +✅ 无效的 voteToken → 提示登录 +❌ 网络连接失败 → 自动回退本地数据 +⚠️ 后端数据不完整 → 使用本地数据 +``` + +**用户反馈** +- 加载状态提示(获取数据/生成图片) +- 错误信息弹窗 +- 降级策略透明化("已使用本地数据") + +## 文件结构 + +``` +packages/vote/src/ +├── common/ +│ ├── components/ +│ │ ├── ExportCharacterVoteImage.vue # 角色投票导出组件 +│ │ ├── ExportCoupleVoteImage.vue # CP投票导出组件 +│ │ └── ExportMusicVoteImage.vue # 音乐投票导出组件 +│ └── lib/ +│ ├── exportVoteData.ts # 投票数据导出工具 +│ ├── voteDataSource.ts # 统一数据访问层 +│ └── testHelper.ts # 测试环境辅助工具 +``` + +## 使用示例 + +### 用户操作流程 + +1. **完成投票**:在相应页面完成角色/CP/音乐投票 +2. **点击导出**:点击"导出为图片"按钮 +3. **预览生成**:查看生成的投票卡片 +4. **保存或分享**:下载图片或直接分享 + +### 开发者测试流程 + +```bash +# 1. 启动开发环境 +pnpm dev + +# 2. 打开浏览器控制台 +# 3. 设置测试数据 +testHelper.setupAllTestVotes() + +# 4. 测试导出功能 +# - 访问角色投票页面 → 点击"导出角色投票为图片" +# - 访问CP投票页面 → 点击"导出CP投票为图片" +# - 访问音乐投票页面 → 点击"导出音乐投票为图片" + +# 5. 测试数据源切换 +testHelper.setDataSourceMode('graphql') # 测试GraphQL模式 +testHelper.setDataSourceMode('local') # 测试本地模式 +testHelper.setDataSourceMode('auto') # 测试自动模式(默认) + +# 6. 清理测试数据 +testHelper.clearTestUserData() +``` + + + +## 注意事项 + +### 生产环境配置 + +1. **后端API**:确保GraphQL API已部署并正常运行 +2. **CDN配置**:配置图片资源的CORS和缓存策略 +3. **图片优化**:考虑图片压缩和懒加载优化 + +### 开发环境调试 + +- 使用 `testHelper` 快速设置测试数据 +- 检查浏览器控制台的详细日志 +- 使用 `checkTestStatus()` 查看当前状态 +- 必要时使用 `clearTestUserData()` 清理数据 + +### 已知限制 + +- 图片生成依赖 `html2canvas`,复杂CSS可能有兼容性问题 +- 大量图片可能导致性能问题 +- Web Share API在某些浏览器不支持(降级为下载) + +--- +## Todo List + +### 1. 同人和问卷结果导出 + +我们目前没有设计好同人和问卷结果的导出样式,所以暂时没有加这两个类型的导出功能。后续需要设计好样式后再添加。 + +一个比较大的问题是同人作品的自由度太高了,可能需要设计一个比较通用的样式,或者提供一些自定义选项让用户选择展示哪些信息。 + +### 2. 服务器资源代理配置 + +重复一下这个问题,由于我们需要把图片资源画到Canvas上面,而这比直接展示图片更严格,所以在开发环境我们通过Vite的代理加载图片资源,生产环境需要确保这些资源支持跨域访问,或者配置服务器进行代理。在其它地方因为只是把“图片挂上去”,所以不需要特别处理。 + +### 3. 角色和音乐颜色信息 + +目前的颜色配置非常匮乏,角色虽然有颜色字段,但全都是同一个颜色,音乐完全没有颜色字段。 + +我非常建议我们有时间通过一些调查或者拍脑袋的方式,给每个角色和音乐配置一个合理的颜色,这样生成的图片会更好看,也更有辨识度,要不然全是默认颜色或者随机颜色都很抽象。 + +### 4. 模块化组件 + +在一些地方换用Naive-UI的组件,并且尽可能把复用的部分抽离成独立组件和脚本,让它维护起来更方便一些。 + +## Git Commit 信息 + +``` +feat: 添加投票导出图片功能,支持角色/CP/音乐三个投票类型 + +新增三个投票导出组件,将用户投票数据生成精美的图片卡片: + +- ExportCharacterVoteImage.vue - 角色投票导出 + * 展示本命角色大卡片和其他角色网格布局 + * 支持角色头像、姓名、作品、理由等信息展示 + * 动态生成主题色背景和装饰效果 + +- ExportCoupleVoteImage.vue - CP投票导出 + * 支持2-3人CP组合展示 + * 显示主动方标记(下部弓形区域) + * 本命CP和其他CP分层展示,每张卡片独立主题色 + +- ExportMusicVoteImage.vue - 音乐投票导出 + * 展示本命音乐和其他音乐投票 + * 显示曲目名称、原名、专辑等信息 + * 基于曲目名称哈希自动生成主题色 + +技术特性: +- 使用 html2canvas 进行离屏DOM渲染,生成高质量PNG图片 +- 支持预览、下载、分享三种操作 +- 兼容 Web Share API(移动端) +- 自动等待图片加载完成后再生成 +- 支持开发环境CDN代理 + +数据层设计: +- exportVoteData.ts:提供统一数据导出接口 + * 支持 localStorage 和 GraphQL 双数据源 + * 实现数据映射:完整数据精简为 {id, isHonmei, reason} + * 智能回退机制:GraphQL失败或数据不完整时自动切换到本地数据 + * 严格校验:确保本命角色/CP/音乐存在 + +- voteDataSource.ts:统一数据访问层 + * 抽象数据源模式:'local' | 'graphql' | 'auto' + * auto模式优先GraphQL,失败时回退到localStorage + * 提供错误分类识别:网络错误、认证错误、服务器错误 + * 统一GraphQL查询定义和结果映射 + * 支持五大投票类型:character/music/couple/doujin/questionnaire + +- testHelper.ts:测试环境辅助工具 + * 提供 setupAllTestVotes() 一键设置完整测试数据 + * 支持自定义测试数据设置(角色/CP/音乐) + * 集成数据源模式控制 + * 提供便捷的控制台API + * 支持状态检查和数据清理 + +背景说明: +- 由于后端GraphQL API尚未完成,实现了基于localStorage的假数据测试 +- 通过抽象数据访问层,实现了兼容GraphQL和本地数据的统一接口 +- 后端API就绪后,只需将数据源模式切换为'graphql'即可无缝迁移 +- 当前默认使用'auto'模式,优先GraphQL,失败时自动降级到本地数据 + +测试方式: +1. pnpm dev 启动开发环境 +2. 打开浏览器控制台,执行 testHelper.setupAllTestVotes() +3. 访问投票页面,点击"导出为图片"按钮 +4. 查看生成的投票卡片,测试下载和分享功能 +5. 使用 testHelper.setDataSourceMode('graphql') 测试GraphQL模式 +``` + +--- + +**更新日期**:2025-02-16 +**负责人**:Renko_1055 \ No newline at end of file diff --git a/packages/vote/src/common/components/ExportCharacterVoteImage.vue b/packages/vote/src/common/components/ExportCharacterVoteImage.vue index 1b33da0..605d971 100644 --- a/packages/vote/src/common/components/ExportCharacterVoteImage.vue +++ b/packages/vote/src/common/components/ExportCharacterVoteImage.vue @@ -11,9 +11,15 @@
+
-

正在生成图片...

+

{{ generatingText }}

@@ -146,10 +152,11 @@ diff --git a/packages/vote/src/common/components/ExportMusicVoteImage.vue b/packages/vote/src/common/components/ExportMusicVoteImage.vue new file mode 100644 index 0000000..dc7eecd --- /dev/null +++ b/packages/vote/src/common/components/ExportMusicVoteImage.vue @@ -0,0 +1,382 @@ + + + diff --git a/packages/vote/src/common/lib/exportVoteData.ts b/packages/vote/src/common/lib/exportVoteData.ts index cfc4a65..b7ec82b 100644 --- a/packages/vote/src/common/lib/exportVoteData.ts +++ b/packages/vote/src/common/lib/exportVoteData.ts @@ -1,29 +1,30 @@ /** - * 临时投票数据导出工具 - * - * 注意:这是临时实现,数据从本地 localStorage 和现有状态管理中读取 - * 待后端 API 完成后,应该替换为真实的 GraphQL 查询 - * - * 预期后端 API: - * query GetMyVoteData($voteToken: String!) { - * getMyVoteData(voteToken: $voteToken) { - * characters { id name first reason } - * music { id name first reason } - * couples { idA idB idC active first reason } - * doujins { id name circle reason } - * questionnaire { ... } - * } - * } + * 投票数据导出工具 + * + * 支持从 localStorage 或 GraphQL 获取数据,通过 voteDataSource.ts 统一管理 + * + * 使用方式: + * 1. 设置数据源模式:setDataSourceMode('local' | 'graphql' | 'auto') + * 2. 调用 exportVoteData() 导出数据 */ -import { characters, characterHonmei } from '@/vote-character/lib/voteData' +import { characters, characterHonmei, characters as allCharacters } from '@/vote-character/lib/voteData' import { charactersVoted } from '@/vote-character/lib/characterList' import { characterList } from '@/vote-character/lib/characterList' import { Character } from '@/vote-character/lib/character' +import { couples } from '@/vote-couple/lib/voteData' +import { Couple } from '@/vote-couple/lib/couple' +import { musics } from '@/vote-music/lib/voteData' +import { musicsVoted } from '@/vote-music/lib/musicList' +import { Music } from '@touhou-vote/shared/data/music' import { voteToken } from '@/home/lib/user' +import { fetchVoteData, type DataSourceMode } from './voteDataSource' +import type { CharacterSubmitQuery, CpSubmitQuery, MusicSubmitQuery } from '@/graphql/__generated__/graphql' +import { character0 } from '@/vote-character/lib/character' +import { music0 } from '@/vote-music/lib/music' /** - * 获取角色投票数据用于导出 + * 获取角色投票数据用于导出(从 localStorage) * * 只返回 id + reason + honmei * 注意:非本命角色不需要 reason(可选),只有本命角色需要填写 @@ -43,20 +44,238 @@ export function getExportCharacterData() { } /** - * 模拟异步获取投票数据(待替换为真实 API) + * 从指定数据源获取角色投票数据用于导出 + * + * @param dataSourceMode 数据源模式:'local' | 'graphql' | 'auto' + * @returns 返回 { data: 角色投票数据数组, error: 错误信息, usedMode: 使用的模式 } */ -export async function exportVoteData() { - // 模拟网络延迟 - await new Promise(resolve => setTimeout(resolve, 500)) +export async function getExportCharacterDataFromDataSource(dataSourceMode?: DataSourceMode) { + const result = await fetchVoteData('character', dataSourceMode) + + if (result.usedMode === 'local') { + const localData = getExportCharacterData() + return { + data: localData, + error: result.error, + usedMode: 'local', + } + } + + if (!result.data || result.data.length === 0) { + // 如果没有数据,回退到本地存储 + const localData = getExportCharacterData() + console.log('[getExportCharacterDataFromDataSource] 回退到本地数据:', localData) + return { + data: localData, + error: result.error, + usedMode: result.usedMode + } + } + + const mappedData = result.data.map((char) => ({ + id: char.id, + isHonmei: char.first || false, + reason: char.first ? (char.reason || '') : '', + })) + + console.log('[getExportCharacterDataFromDataSource] GraphQL 数据映射结果:', mappedData) + + // 检查是否有本命角色 + const hasHonmei = mappedData.some(char => char.isHonmei) + + if (!hasHonmei && dataSourceMode !== 'local') { + // 如果 GraphQL 返回的数据中没有本命角色,且不是强制使用 local 模式 + // 说明后端数据可能不完整,回退到本地数据 + const localData = getExportCharacterData() + const localHasHonmei = localData.some(char => char.isHonmei) + + if (localHasHonmei) { + console.log('[getExportCharacterDataFromDataSource] GraphQL 数据中没有本命角色,回退到本地数据') + console.log('[getExportCharacterDataFromDataSource] 本地数据:', localData) + return { + data: localData, + error: result.error || '后端数据不完整,已使用本地数据', + usedMode: 'local' + } + } + } return { - voteToken: voteToken.value, - exportTime: new Date().toISOString(), - characters: getExportCharacterData(), - // TODO: 添加其他投票类型 - // music: [], - // couples: [], - // doujins: [], - // questionnaire: {} - } -} \ No newline at end of file + data: mappedData, + error: result.error, + usedMode: result.usedMode + } +} + +/** + * 获取CP投票数据用于导出(从 localStorage) + * + * 只返回 idA, idB, idC, active, honmei, reason + * 其他信息(name, color, date, work, image)从 characterList 中读取 + */ +export function getExportCoupleData() { + // 获取已投票的CP(排除无效CP) + const votedCouples = couples.value.filter(couple => couple.valid) + + // 返回完整的CP数据(包含角色信息和seme索引) + // 注意:active字段类型需要与GraphQL的Maybe保持一致 + return votedCouples.map(couple => ({ + idA: couple.characters[0]?.id || '0', + idB: couple.characters[1]?.id || '0', + idC: couple.characters[2]?.id || '0', + active: couple.seme >= 0 ? (couple.characters[couple.seme]?.id || null) : null, + isHonmei: couple.honmei, + reason: couple.reason || '' + })) +} + +/** + * 从指定数据源获取CP投票数据用于导出 + * + * @param dataSourceMode 数据源模式:'local' | 'graphql' | 'auto' + * @returns 返回 { data: CP投票数据数组, error: 错误信息, usedMode: 使用的模式 } + */ +export async function getExportCoupleDataFromDataSource(dataSourceMode?: DataSourceMode) { + const result = await fetchVoteData('couple', dataSourceMode) + + if (result.usedMode === 'local') { + const localData = getExportCoupleData() + return { + data: localData, + error: result.error, + usedMode: 'local', + } + } + + if (!result.data || result.data.length === 0) { + // 如果没有数据,回退到本地存储 + const localData = getExportCoupleData() + console.log('[getExportCoupleDataFromDataSource] 回退到本地数据:', localData) + return { + data: localData, + error: result.error, + usedMode: result.usedMode + } + } + + const mappedData = result.data.map((cp) => ({ + idA: cp.idA, + idB: cp.idB, + idC: cp.idC || '0', + active: cp.active, + isHonmei: cp.first || false, + reason: cp.reason || '', + })) + + console.log('[getExportCoupleDataFromDataSource] GraphQL 数据映射结果:', mappedData) + + // 检查是否有本命CP + const hasHonmei = mappedData.some(cp => cp.isHonmei) + + if (!hasHonmei && dataSourceMode !== 'local') { + // 如果 GraphQL 返回的数据中没有本命CP,且不是强制使用 local 模式 + // 说明后端数据可能不完整,回退到本地数据 + const localData = getExportCoupleData() + const localHasHonmei = localData.some(cp => cp.isHonmei) + + if (localHasHonmei) { + console.log('[getExportCoupleDataFromDataSource] GraphQL 数据中没有本命CP,回退到本地数据') + console.log('[getExportCoupleDataFromDataSource] 本地数据:', localData) + return { + data: localData, + error: result.error || '后端数据不完整,已使用本地数据', + usedMode: 'local' + } + } + } + + return { + data: mappedData, + error: result.error, + usedMode: result.usedMode + } +} + +/** + * 获取音乐投票数据用于导出(从 localStorage) + * + * 只返回 id + reason + honmei + * 注意:非本命音乐不需要 reason(可选),只有本命音乐需要填写 + * 其他信息(name, origname, album, image)从 musicList 中读取 + */ +export function getExportMusicData() { + // 获取已投票的音乐(排除空票) + const votedMusics = musics.value.filter(music => music.id !== '00000000') + + // 只返回 id + reason + honmei + // 非本命音乐 reason 可以为空 + return votedMusics.map(music => ({ + id: music.id, + isHonmei: music.honmei, + reason: music.honmei ? (music.reason || '') : '' + })) +} + +/** + * 从指定数据源获取音乐投票数据用于导出 + * + * @param dataSourceMode 数据源模式:'local' | 'graphql' | 'auto' + * @returns 返回 { data: 音乐投票数据数组, error: 错误信息, usedMode: 使用的模式 } + */ +export async function getExportMusicDataFromDataSource(dataSourceMode?: DataSourceMode) { + const result = await fetchVoteData('music', dataSourceMode) + + if (result.usedMode === 'local') { + const localData = getExportMusicData() + return { + data: localData, + error: result.error, + usedMode: 'local', + } + } + + if (!result.data || result.data.length === 0) { + // 如果没有数据,回退到本地存储 + const localData = getExportMusicData() + console.log('[getExportMusicDataFromDataSource] 回退到本地数据:', localData) + return { + data: localData, + error: result.error, + usedMode: result.usedMode + } + } + + const mappedData = result.data.map((music) => ({ + id: music.id, + isHonmei: music.first || false, + reason: music.first ? (music.reason || '') : '', + })) + + console.log('[getExportMusicDataFromDataSource] GraphQL 数据映射结果:', mappedData) + + // 检查是否有本命音乐 + const hasHonmei = mappedData.some(music => music.isHonmei) + + if (!hasHonmei && dataSourceMode !== 'local') { + // 如果 GraphQL 返回的数据中没有本命音乐,且不是强制使用 local 模式 + // 说明后端数据可能不完整,回退到本地数据 + const localData = getExportMusicData() + const localHasHonmei = localData.some(music => music.isHonmei) + + if (localHasHonmei) { + console.log('[getExportMusicDataFromDataSource] GraphQL 数据中没有本命音乐,回退到本地数据') + console.log('[getExportMusicDataFromDataSource] 本地数据:', localData) + return { + data: localData, + error: result.error || '后端数据不完整,已使用本地数据', + usedMode: 'local' + } + } + } + + return { + data: mappedData, + error: result.error, + usedMode: result.usedMode + } +} diff --git a/packages/vote/src/common/lib/testErrorHandling.ts b/packages/vote/src/common/lib/testErrorHandling.ts new file mode 100644 index 0000000..ece54c0 --- /dev/null +++ b/packages/vote/src/common/lib/testErrorHandling.ts @@ -0,0 +1,170 @@ +/** + * 测试错误处理功能 + * + * 此文件用于测试不同错误场景下的系统行为 + * + * 测试场景: + * 1. 无效的 token - 应该显示 "无效的 voteToken,请检查您的登录状态" + * 2. 网络错误 - 应该显示 "网络连接失败,请检查网络连接" + * 3. 服务器错误 - 应该显示具体的错误信息 + * 4. GraphQL 模式下失败 - 应该显示错误提示并回退到本地数据 + * 5. Local 模式下 - 不应该显示网络相关的错误 + */ + +import { setDataSourceMode, getDataSourceMode, fetchVoteData } from './voteDataSource' +import { setupTestUser, setupQuickTestVotes, checkTestStatus, setTestDataSourceMode } from './testHelper' +import { voteToken } from '@/home/lib/user' + +/** + * 测试 1: 使用无效的 token 尝试获取 GraphQL 数据 + */ +export async function testInvalidTokenError() { + console.log('\n=== 测试 1: 无效 Token 错误 ===') + + // 设置测试用户(使用无效 token) + setupTestUser() + + // 设置一个明显无效的 token + voteToken.value = 'invalid_test_token_12345' + + // 设置为 GraphQL 模式 + setDataSourceMode('graphql') + + console.log('当前模式:', getDataSourceMode()) + console.log('Token:', voteToken.value) + + // 尝试获取数据 + const result = await fetchVoteData('character') + + console.log('结果:') + console.log('- 数据:', result.data ? '获取成功' : '获取失败') + console.log('- 错误:', result.error) + console.log('- 使用的模式:', result.usedMode) + + return result +} + +/** + * 测试 2: 设置本地数据,然后尝试 GraphQL + */ +export async function testLocalDataFallback() { + console.log('\n=== 测试 2: 本地数据回退 ===') + + // 设置测试数据 + setupQuickTestVotes() + + // 设置为 GraphQL 模式(但 token 可能无效) + setDataSourceMode('graphql') + + console.log('当前模式:', getDataSourceMode()) + console.log('本地数据已设置') + + // 尝试获取数据 + const result = await fetchVoteData('character') + + console.log('结果:') + console.log('- 数据:', result.data ? '获取成功' : '获取失败') + console.log('- 错误:', result.error) + console.log('- 使用的模式:', result.usedMode) + + return result +} + +/** + * 测试 3: 使用本地模式 + */ +export async function testLocalMode() { + console.log('\n=== 测试 3: 本地模式 ===') + + // 设置测试数据 + setupQuickTestVotes() + + // 设置为 local 模式 + setDataSourceMode('local') + + console.log('当前模式:', getDataSourceMode()) + + // 获取数据 + const result = await fetchVoteData('character') + + console.log('结果:') + console.log('- 数据:', result.data ? '获取成功' : '获取失败') + console.log('- 错误:', result.error) + console.log('- 使用的模式:', result.usedMode) + + return result +} + +/** + * 测试 4: 使用 auto 模式 + */ +export async function testAutoMode() { + console.log('\n=== 测试 4: 自动模式 ===') + + // 设置测试数据 + setupQuickTestVotes() + + // 设置为 auto 模式 + setDataSourceMode('auto') + + console.log('当前模式:', getDataSourceMode()) + + // 获取数据 + const result = await fetchVoteData('character') + + console.log('结果:') + console.log('- 数据:', result.data ? '获取成功' : '获取失败') + console.log('- 错误:', result.error) + console.log('- 使用的模式:', result.usedMode) + + return result +} + +/** + * 运行所有测试 + */ +export async function runAllErrorTests() { + console.log('╔═══════════════════════════════════════════════════════════╗') + console.log('║ 开始错误处理测试 ║') + console.log('╚═══════════════════════════════════════════════════════════╝') + + try { + await testInvalidTokenError() + await testLocalDataFallback() + await testLocalMode() + await testAutoMode() + + console.log('\n╔═══════════════════════════════════════════════════════════╗') + console.log('║ 所有测试完成 ║') + console.log('╚═══════════════════════════════════════════════════════════╝') + } catch (error) { + console.error('测试过程中出错:', error) + } +} + +// 在开发环境中暴露到全局 +if (import.meta.env.DEV) { + ;(window as any).errorTests = { + testInvalidTokenError, + testLocalDataFallback, + testLocalMode, + testAutoMode, + runAllErrorTests + } + + console.log(` +╔═══════════════════════════════════════════════════════════╗ +║ 错误处理测试工具已加载 ✅ ║ +╚═══════════════════════════════════════════════════════════╝ + +💡 在控制台使用以下命令测试错误处理: + + errorTests.testInvalidTokenError() - 测试无效 token 错误 + errorTests.testLocalDataFallback() - 测试本地数据回退 + errorTests.testLocalMode() - 测试本地模式 + errorTests.testAutoMode() - 测试自动模式 + errorTests.runAllErrorTests() - 运行所有测试 + +🎯 快速测试: errorTests.runAllErrorTests() + `) +} \ No newline at end of file diff --git a/packages/vote/src/common/lib/testHelper.ts b/packages/vote/src/common/lib/testHelper.ts index 6352a31..157b1ff 100644 --- a/packages/vote/src/common/lib/testHelper.ts +++ b/packages/vote/src/common/lib/testHelper.ts @@ -6,12 +6,22 @@ * 使用方式: * 1. 在浏览器控制台中调用 setupTestUser() * 2. 或者直接访问 /test 页面使用测试工具 + * + * 新增功能: + * - 支持设置数据源模式(localStorage / GraphQL / auto) + * - 通过 setDataSourceMode() 控制数据获取方式 */ import { voteToken, isLogin, setUserDataToLocalStorage, user, createDefaultVoter, enableDevMode, disableDevMode } from '@/home/lib/user' import { characters } from '@/vote-character/lib/voteData' import { characterList } from '@/vote-character/lib/characterList' import { Character } from '@/vote-character/lib/character' +import { couples, CPVOTENUM } from '@/vote-couple/lib/voteData' +import { Couple } from '@/vote-couple/lib/couple' +import { musics, MUSICVOTENUM } from '@/vote-music/lib/voteData' +import { Music } from '@/vote-music/lib/music' +import { musicList } from '@/vote-music/lib/musicList' +import { setDataSourceMode, getDataSourceMode, type DataSourceMode } from './voteDataSource' /** * 模拟登录用户 @@ -78,7 +88,7 @@ export function setupTestCharacterVotes(honmeiName?: string, otherNames: string[ // 设置其他角色 otherNames.forEach((name, index) => { - if (index >= 7) return // 最多7个普通角色 + if (index >= 9) return // 最多9个普通角色 const char = characterList.find(c => c.name.includes(name)) if (char) { const newChar = new Character() @@ -100,7 +110,7 @@ export function setupTestCharacterVotes(honmeiName?: string, otherNames: string[ * 快速设置常见角色投票数据 */ export function setupQuickTestVotes() { - console.log('🔧 设置快速测试数据...') + console.log('🔧 设置快速角色测试数据...') // 模拟登录 setupTestUser() @@ -111,10 +121,111 @@ export function setupQuickTestVotes() { ['雾雨魔理沙', '琪露诺', '十六夜咲夜', '蕾米莉亚', '芙兰朵露', '帕秋莉', '爱丽丝'] // 其他7个 ) - console.log('✅ 快速测试数据设置完成!') + console.log('✅ 快速角色测试数据设置完成!') console.log('💡 现在可以在首页点击头像,使用"导出角色投票为图片"功能了') } +/** + * 设置音乐投票数据 + * 只存储 id + reason + honmei,其他信息从 musicList 中读取 + * @param honmeiName 本命音乐名称 + * @param otherNames 其他音乐名称数组 + */ +export function setupTestMusicVotes(honmeiName?: string, otherNames: string[] = []) { + console.log('🔧 设置测试音乐投票数据...') + + // 清空现有数据(使用 Music 类) + musics.value = new Array(MUSICVOTENUM).fill(null).map(() => new Music()) + + // 设置本命音乐 + if (honmeiName) { + const honmeiMusic = musicList.find(m => m.name.includes(honmeiName)) + if (honmeiMusic) { + const newHonmei = new Music() + newHonmei.id = honmeiMusic.id + newHonmei.honmei = true + newHonmei.reason = '因为太好听了,循环播放停不下来!' + musics.value[0] = newHonmei + console.log(`✅ 设置本命音乐: ${honmeiMusic.name} (ID: ${honmeiMusic.id})`) + } + } + + // 设置其他音乐 + otherNames.forEach((name, index) => { + if (index >= MUSICVOTENUM - 1) return + const music = musicList.find(m => m.name.includes(name)) + if (music) { + const newMusic = new Music() + newMusic.id = music.id + newMusic.honmei = false + newMusic.reason = '' + musics.value[index + 1] = newMusic + console.log(`✅ 设置音乐 ${index + 1}: ${music.name} (ID: ${music.id})`) + } + }) + + // 保存到 localStorage + localStorage.setItem('musics', JSON.stringify(musics.value)) + + console.log('✅ 音乐投票数据设置完成(只存储 id + reason + honmei)') +} + +/** + * 快速设置完整的角色和CP投票数据 + * 同时配置角色投票和CP投票 + */ +export function setupAllTestVotes() { + console.log('🔧 设置完整测试数据(角色 + CP)...') + + // 模拟登录 + setupTestUser() + + // 设置角色投票(博丽灵梦 + 常见角色) + setupTestCharacterVotes( + '博丽灵梦', // 本命 + ['雾雨魔理沙', '琪露诺', '十六夜咲夜', '蕾米莉亚', '芙兰朵露', '帕秋莉', '爱丽丝'] // 其他7个 + ) + + // 设置CP投票 + setupTestCoupleVotes( + // 本命CP + [ + { + names: ['博丽灵梦', '雾雨魔理沙'], + active: '博丽灵梦', + reason: '最经典的组合!永远支持红白组!' + } + ], + // 其他CP + [ + { + names: ['琪露诺', '大妖精','八云紫'], + active: '琪露诺', + reason: '最强⑨和她的仆从' + }, + { + names: ['十六夜咲夜', '蕾米莉亚'], + active: '十六夜咲夜', + reason: '红魔馆的日常' + }, + { + names: ['西行寺幽幽子', '魂魄妖梦'], + active: '西行寺幽幽子', + reason: '主仆关系' + } + ] + ) + + // 设置音乐投票 + setupTestMusicVotes( + '幽雅地绽放吧,墨染的樱花', + ['U.N.オーエンは彼女なのか?', '上海红茶馆', '亡き王女の为のセプテット'] + ) + + console.log('✅ 完整测试数据设置完成!') + console.log('💡 现在可以在首页点击头像,使用"导出角色/CP/音乐投票为图片"功能了') +} + /** * 获取可用的角色列表(用于测试) */ @@ -157,6 +268,176 @@ export function clearTestUserData() { location.reload() } +/** + * 设置CP投票数据 + * @param honmeiCouples 本命CP配置数组 [{names: ['角色1', '角色2'], active: '角色1', reason: '理由'}] + * @param otherCouples 其他CP配置数组 + */ +export function setupTestCoupleVotes( + honmeiCouples: Array<{ names: string[]; active?: string; reason?: string }> = [], + otherCouples: Array<{ names: string[]; active?: string; reason?: string }> = [] +) { + console.log('🔧 设置测试CP投票数据...') + + // 清空现有CP数据 + couples.value = new Array(CPVOTENUM).fill(null).map(() => new Couple()) + + const setupCouple = ( + coupleConfig: { names: string[]; active?: string; reason?: string }, + index: number, + isHonmei: boolean + ) => { + const newCouple = new Couple() + newCouple.honmei = isHonmei + newCouple.reason = coupleConfig.reason || '' + + // 设置角色 + coupleConfig.names.forEach((name, charIndex) => { + if (charIndex >= 3) return // 最多3个角色 + const char = characterList.find(c => c.name.includes(name)) + if (char) { + newCouple.characters[charIndex] = char + console.log(` ${isHonmei ? '本命' : '其他'}CP[${index}] 角色${charIndex}: ${char.name}`) + } + }) + + // 设置主动方 + if (coupleConfig.active) { + const activeIndex = newCouple.characters.findIndex(c => c && c.name.includes(coupleConfig.active!)) + if (activeIndex >= 0) { + newCouple.seme = activeIndex + console.log(` ${isHonmei ? '本命' : '其他'}CP[${index}] 主动方: ${coupleConfig.active} (索引${activeIndex})`) + } + } + + newCouple.valid = true + return newCouple + } + + // 设置本命CP(第一个) + if (honmeiCouples.length > 0) { + const honmeiCouple = setupCouple(honmeiCouples[0], 0, true) + couples.value[0] = honmeiCouple + console.log(`✅ 设置本命CP: ${honmeiCouple.characters.map(c => c?.name).join(' × ')}`) + } + + // 设置其他CP + let otherIndex = honmeiCouples.length > 0 ? 1 : 0 + otherCouples.forEach((config) => { + if (otherIndex >= CPVOTENUM) return + const couple = setupCouple(config, otherIndex, false) + couples.value[otherIndex] = couple + console.log(`✅ 设置其他CP[${otherIndex}]: ${couple.characters.map(c => c?.name).join(' × ')}`) + otherIndex++ + }) + + // 保存到 localStorage + localStorage.setItem('couples', JSON.stringify(couples.value)) + + console.log('✅ CP投票数据设置完成') +} + +/** + * 快速设置常见CP投票数据 + */ +export function setupQuickTestCoupleVotes() { + console.log('🔧 设置快速CP测试数据...') + + // 模拟登录 + setupTestUser() + + // 设置CP投票 + setupTestCoupleVotes( + // 本命CP + [ + { + names: ['博丽灵梦', '雾雨魔理沙'], + active: '博丽灵梦', + reason: '最经典的组合!永远支持红白组!' + } + ], + // 其他CP + [ + { + names: ['琪露诺', '大妖精'], + active: '琪露诺', + reason: '最强⑨和她的仆从' + }, + { + names: ['十六夜咲夜', '蕾米莉亚'], + active: '十六夜咲夜', + reason: '红魔馆的日常' + }, + { + names: ['西行寺幽幽子', '魂魄妖梦'], + active: '西行寺幽幽子', + reason: '主仆关系' + } + ] + ) + + console.log('✅ 快速CP测试数据设置完成!') + console.log('💡 现在可以在CP投票页面,使用"导出CP投票为图片"功能了') +} + +/** + * 快速设置常见音乐投票数据 + */ +export function setupQuickTestMusicVotes() { + console.log('🔧 设置快速音乐测试数据...') + + // 模拟登录 + setupTestUser() + + // 设置音乐投票 + setupTestMusicVotes( + '幽雅地绽放吧,墨染的樱花', + ['U.N.オーエンは彼女なのか?', '上海红茶馆', '亡き王女の为のセプテット'] + ) + + console.log('✅ 快速音乐测试数据设置完成!') + console.log('💡 现在可以在音乐投票页面,使用"导出音乐投票为图片"功能了') +} + +/** + * 获取可用的音乐列表(用于测试) + */ +export function getAvailableMusics() { + const commonMusics = musicList + .filter(m => m.name.includes('红魔') || m.name.includes('樱花') || m.name.includes('月')) + .slice(0, 20) + + console.log('📋 可用音乐列表(前20个):') + commonMusics.forEach((music, index) => { + console.log(`${index + 1}. ${music.name} (ID: ${music.id})`) + }) + + return commonMusics +} + +/** + * 获取可用的CP组合示例(用于测试) + */ +export function getAvailableCoupleExamples() { + const examples = [ + { names: ['博丽灵梦', '雾雨魔理沙'], desc: '红白组' }, + { names: ['琪露诺', '大妖精'], desc: '冰精组' }, + { names: ['十六夜咲夜', '蕾米莉亚'], desc: '红魔馆组' }, + { names: ['西行寺幽幽子', '魂魄妖梦'], desc: '冥界组' }, + { names: ['八云紫', '八云蓝'], desc: '八云组' }, + { names: ['蓬莱山辉夜', '藤原妹红'], desc: '竹取组' }, + { names: ['东风谷早苗', '八坂神奈子'], desc: '守矢组' }, + { names: ['古明地恋', '古明地觉'], desc: '古明地组' } + ] + + console.log('📋 可用CP组合示例:') + examples.forEach((cp, index) => { + console.log(`${index + 1}. ${cp.names.join(' × ')} - ${cp.desc}`) + }) + + return examples +} + /** * 检查当前登录状态 */ @@ -165,6 +446,7 @@ export function checkTestStatus() { console.log('登录状态:', isLogin.value ? '✅ 已登录' : '❌ 未登录') console.log('Token:', voteToken.value || '(空)') console.log('用户名:', user.value.username || '(未设置)') + console.log('数据源模式:', getDataSourceMode()) const savedCharacters = JSON.parse(localStorage.getItem('characters') || '[]') const validCharacters = savedCharacters.filter((c: any) => c.id !== '0') @@ -172,6 +454,33 @@ export function checkTestStatus() { if (validCharacters.length > 0) { console.log('已投票角色:', validCharacters.map((c: any) => c.name)) } + + const savedCouples = JSON.parse(localStorage.getItem('couples') || '[]') + const validCouples = savedCouples.filter((c: any) => c.valid) + console.log('CP投票数量:', validCouples.length) + if (validCouples.length > 0) { + console.log('已投票CP:', validCouples.map((c: any) => + c.characters.filter((char: any) => char.id !== '0').map((char: any) => char.name).join(' × ') + )) + } +} + +/** + * 设置数据源模式 + * @param mode 'local' - 使用本地存储 | 'graphql' - 使用 GraphQL | 'auto' - 自动选择 + */ +export function setTestDataSourceMode(mode: DataSourceMode) { + setDataSourceMode(mode) + console.log(`✅ 数据源模式已设置为: ${mode}`) +} + +/** + * 获取当前数据源模式 + */ +export function getTestDataSourceMode(): DataSourceMode { + const mode = getDataSourceMode() + console.log(`当前数据源模式: ${mode}`) + return mode } // 在控制台暴露全局函数(仅在开发环境) @@ -180,9 +489,18 @@ if (import.meta.env.DEV) { setupTestUser, setupTestCharacterVotes, setupQuickTestVotes, + setupTestCoupleVotes, + setupQuickTestCoupleVotes, + setupTestMusicVotes, + setupQuickTestMusicVotes, + setupAllTestVotes, getAvailableCharacters, + getAvailableMusics, + getAvailableCoupleExamples, clearTestUserData, - checkTestStatus + checkTestStatus, + setDataSourceMode: setTestDataSourceMode, + getDataSourceMode: getTestDataSourceMode } console.log(` @@ -192,14 +510,28 @@ if (import.meta.env.DEV) { 💡 在控制台使用以下命令: - testHelper.setupQuickTestVotes() - 快速设置完整测试数据 + testHelper.setupAllTestVotes() - 快速设置完整测试数据(角色+CP) + testHelper.setupQuickTestVotes() - 快速设置角色测试数据 + testHelper.setupQuickTestCoupleVotes() - 快速设置CP测试数据 testHelper.setupTestUser() - 仅设置测试用户 testHelper.setupTestCharacterVotes('灵梦', ['魔理沙', '琪露诺']) - 自定义角色投票 + testHelper.setupTestMusicVotes('樱花', ['红魔', '月']) + - 自定义音乐投票 + testHelper.setupTestCoupleVotes( + [{names: ['灵梦', '魔理沙'], active: '灵梦', reason: '理由'}], + [{names: ['琪露诺', '大妖精']}] + ) - 自定义CP投票 testHelper.getAvailableCharacters() - 查看可用角色 + testHelper.getAvailableMusics() - 查看可用音乐 + testHelper.getAvailableCoupleExamples() - 查看可用CP示例 testHelper.checkTestStatus() - 检查当前状态 testHelper.clearTestUserData() - 清理测试数据 -🎯 快速开始: testHelper.setupQuickTestVotes() +🎯 快速开始: + testHelper.setupAllTestVotes() // 一键设置所有测试数据(推荐) + testHelper.setupQuickTestVotes() // 仅测试角色投票导出 + testHelper.setupQuickTestCoupleVotes() // 仅测试CP投票导出 + testHelper.setupQuickTestMusicVotes() // 仅测试音乐投票导出 `) } \ No newline at end of file diff --git a/packages/vote/src/common/lib/voteDataSource.ts b/packages/vote/src/common/lib/voteDataSource.ts new file mode 100644 index 0000000..0a1ca39 --- /dev/null +++ b/packages/vote/src/common/lib/voteDataSource.ts @@ -0,0 +1,306 @@ +/** + * 统一数据访问层 + * + * 提供统一的接口来获取投票数据,支持从 localStorage 或 GraphQL 获取 + * + * 使用方式: + * 1. 设置数据源模式:setDataSourceMode('local' | 'graphql' | 'auto') + * 2. 获取数据:fetchVoteData('character') 或 fetchVoteData('music') + */ + +import { ref } from 'vue' +import { useLazyQuery } from '@/graphql' +import { gql } from '@apollo/client/core' +import type { Query, CharacterSubmitQuery } from '@/graphql/__generated__/graphql' +import { voteToken } from '@/home/lib/user' + +/** + * 数据源模式 + */ +export type DataSourceMode = 'local' | 'graphql' | 'auto' + +/** + * 当前数据源模式(全局配置) + */ +const dataSourceMode = ref('auto') + +/** + * 设置数据源模式 + * @param mode 'local' - 强制使用本地存储 + * 'graphql' - 强制使用 GraphQL + * 'auto' - 优先使用 GraphQL,失败时回退到本地存储 + */ +export function setDataSourceMode(mode: DataSourceMode) { + dataSourceMode.value = mode +} + +/** + * 获取当前数据源模式 + */ +export function getDataSourceMode(): DataSourceMode { + return dataSourceMode.value +} + +/** + * 投票数据类型 + */ +export type VoteDataType = 'character' | 'music' | 'couple' | 'doujin' | 'questionnaire' + +/** + * 从本地存储获取投票数据 + */ +function fetchFromLocalStorage(key: string): T[] | null { + try { + const data = localStorage.getItem(key) + if (!data) return null + const parsed = JSON.parse(data) + // 检查是否是空数组 + if (JSON.stringify(parsed) === '[]') return null + return parsed as T[] + } catch (error) { + console.error(`读取本地存储失败 (${key}):`, error) + return null + } +} + +/** + * GraphQL 查询定义 + */ +const GRAPHQL_QUERIES = { + character: gql` + query ($voteToken: String!) { + getSubmitCharacterVote(voteToken: $voteToken) { + characters { + id + first + reason + } + } + } + `, + music: gql` + query ($voteToken: String!) { + getSubmitMusicVote(voteToken: $voteToken) { + music { + id + first + reason + } + } + } + `, + couple: gql` + query ($voteToken: String!) { + getSubmitCPVote(voteToken: $voteToken) { + cps { + idA + idB + idC + active + first + reason + } + } + } + `, + doujin: gql` + query ($voteToken: String!) { + getSubmitDojinVote(voteToken: $voteToken) { + dojins { + author + dojinType + imageUrl + reason + title + url + } + } + } + `, + questionnaire: gql` + query ($voteToken: String!) { + getSubmitPaperVote(voteToken: $voteToken) { + papersJson + } + } + ` +} as const + +/** + * GraphQL 结果映射函数 + */ +const extractGraphQLData = { + character: (result: Query) => result.getSubmitCharacterVote?.characters || [], + music: (result: Query) => result.getSubmitMusicVote?.music || [], + couple: (result: Query) => result.getSubmitCPVote?.cps || [], + doujin: (result: Query) => result.getSubmitDojinVote?.dojins || [], + questionnaire: (result: Query) => { + const json = result.getSubmitPaperVote?.papersJson + if (!json) return [] + try { + return JSON.parse(json) + } catch { + return [] + } + } +} + +/** + * 从 GraphQL 获取投票数据 + * + * @returns 返回 { data: T[] | null, error: string | null } + */ +async function fetchFromGraphQL(dataType: VoteDataType): Promise<{ data: T[] | null; error: string | null }> { + try { + const query = GRAPHQL_QUERIES[dataType] + if (!query) { + console.error(`不支持的投票类型: ${dataType}`) + return { data: null, error: '不支持的投票类型' } + } + + const { load, result, error: loadErrorRef } = useLazyQuery(query, { voteToken: voteToken.value }, { fetchPolicy: 'network-only' }) + + await load() + + // 检查加载错误 - 使用 .value 访问 Ref + const loadError = loadErrorRef.value + if (loadError) { + console.error(`GraphQL 查询加载失败 (${dataType}):`, loadError) + + // 尝试识别错误类型 + let errorMessage = '获取数据失败' + const errorMsg = loadError.message || '' + const graphQLErrors = loadError.graphQLErrors || [] + + if (errorMsg.toLowerCase().includes('unauthorized') || + errorMsg.toLowerCase().includes('token') || + errorMsg.toLowerCase().includes('auth') || + graphQLErrors.some((e: any) => + e.message?.toLowerCase().includes('token') || + e.message?.toLowerCase().includes('unauthorized') + )) { + errorMessage = '无效的 voteToken,请检查您的登录状态' + } else if (loadError.networkError) { + errorMessage = '网络连接失败,请检查网络连接' + } else if (errorMsg) { + errorMessage = `服务器错误: ${errorMsg}` + } + + return { data: null, error: errorMessage } + } + + const data = extractGraphQLData[dataType](result.value as Query) + + // 严格检查:数据必须非空 + if (!data || data.length === 0) { + console.warn(`GraphQL 返回空数据 (${dataType})`) + return { data: null, error: null } + } + + // 严格检查:对于角色投票,必须有本命角色 + if (dataType === 'character') { + const hasHonmei = (data as any[]).some((char: any) => char.first === true) + if (!hasHonmei) { + console.error(`GraphQL 返回的角色投票数据没有本命角色 (${dataType}),数据可能不完整或 token 无效`) + return { + data: null, + error: '投票数据不完整(缺少本命角色),请重新登录或检查您的投票状态' + } + } + } + + return { data: data as T[], error: null } + } catch (error: any) { + console.error(`从 GraphQL 获取数据失败 (${dataType}):`, error) + + // 尝试识别错误类型 + let errorMessage = '获取数据失败' + + // 检查是否是认证/token 相关错误 + const errorMsg = error.message || '' + const graphQLErrors = error.graphQLErrors || [] + + if (errorMsg.toLowerCase().includes('unauthorized') || + errorMsg.toLowerCase().includes('token') || + errorMsg.toLowerCase().includes('auth') || + graphQLErrors.some((e: any) => + e.message?.toLowerCase().includes('token') || + e.message?.toLowerCase().includes('unauthorized') + )) { + errorMessage = '无效的 voteToken,请检查您的登录状态' + } else if (error.networkError) { + errorMessage = '网络连接失败,请检查网络连接' + } else if (errorMsg) { + errorMessage = `服务器错误: ${errorMsg}` + } + + return { data: null, error: errorMessage } + } +} + +/** + * 统一获取投票数据 + * + * @param dataType 投票类型 + * @param forceMode 强制指定数据源模式(可选) + * @returns 返回 { data: T[] | null, error: string | null, usedMode: string } + * - data: 投票数据,如果获取失败返回 null + * - error: 错误信息,如果没有错误返回 null + * - usedMode: 实际使用的数据源模式 ('local' | 'graphql' | 'null') + */ +export async function fetchVoteData( + dataType: VoteDataType, + forceMode?: DataSourceMode +): Promise<{ data: T[] | null; error: string | null; usedMode: 'local' | 'graphql' | null }> { + const mode = forceMode ?? dataSourceMode.value + const localStorageKey = `${dataType}s` // characters, musics, couples, doujins + + // 模式:强制使用本地存储 + if (mode === 'local') { + const localData = fetchFromLocalStorage(localStorageKey) + return { data: localData, error: null, usedMode: 'local' } + } + + // 模式:强制使用 GraphQL + if (mode === 'graphql') { + const { data: graphqlData, error: graphqlError } = await fetchFromGraphQL(dataType) + + if (graphqlData) { + return { data: graphqlData, error: null, usedMode: 'graphql' } + } + + // 如果 GraphQL 失败,尝试本地存储作为后备 + console.warn(`GraphQL 获取失败 (${graphqlError}),尝试本地存储 (${dataType})`) + const localData = fetchFromLocalStorage(localStorageKey) + return { data: localData, error: graphqlError, usedMode: localData ? 'local' : null } + } + + // 模式:自动(优先 GraphQL,失败则本地存储) + if (mode === 'auto') { + // 先尝试本地存储,如果有数据则直接返回(离线优先) + const localData = fetchFromLocalStorage(localStorageKey) + + // 尝试 GraphQL + const { data: graphqlData, error: graphqlError } = await fetchFromGraphQL(dataType) + + // 如果 GraphQL 成功,返回 GraphQL 数据 + if (graphqlData) { + return { data: graphqlData, error: null, usedMode: 'graphql' } + } + + // 否则返回本地存储数据 + return { data: localData, error: graphqlError, usedMode: localData ? 'local' : null } + } + + return { data: null, error: '未知的数据源模式', usedMode: null } +} + +/** + * 重置数据源模式为默认值 + */ +export function resetDataSourceMode() { + dataSourceMode.value = 'auto' +} + +// 导出类型 +export type { CharacterSubmitQuery, MusicSubmitQuery, CpSubmitQuery, DojinSubmitQuery } from '@/graphql/__generated__/graphql' \ No newline at end of file diff --git a/packages/vote/src/home/UserHome.vue b/packages/vote/src/home/UserHome.vue index daae051..6094763 100644 --- a/packages/vote/src/home/UserHome.vue +++ b/packages/vote/src/home/UserHome.vue @@ -41,6 +41,12 @@
+
+ +
+
+ +
+
+ +
+
+ +
{ }); } }, + // 解决 thwiki 图片跨域的代理 + '/thwiki-assets': { + target: 'https://static.thwiki.cc', + changeOrigin: true, + secure: false, + rewrite: (path: string) => path.replace(/^\/thwiki-assets/, ''), + configure: (proxy) => { + proxy.on('proxyRes', (proxyRes) => { + proxyRes.headers['Access-Control-Allow-Origin'] = '*'; + proxyRes.headers['Access-Control-Allow-Methods'] = 'GET, OPTIONS'; + proxyRes.headers['Access-Control-Allow-Headers'] = 'X-Requested-With, content-type, Authorization'; + + if (proxyRes.headers['content-type']?.includes('image')) { + proxyRes.headers['cache-control'] = 'public, max-age=31536000'; + } + }); + } + }, }, }, build: {