├── .node-version ├── .eslintignore ├── .prettierignore ├── src ├── assets │ ├── bg.jpg │ ├── bbs.png │ ├── cryo.png │ ├── geo.png │ ├── home.png │ ├── icon.ico │ ├── icon.png │ ├── lock.png │ ├── logo.png │ ├── mora.png │ ├── pyro.png │ ├── task.png │ ├── anemo.png │ ├── cursor.cur │ ├── dendro.png │ ├── electro.png │ ├── hydro.png │ ├── macicon.png │ ├── paimon.gif │ ├── paimon2.gif │ ├── resin.png │ ├── star1.png │ ├── star2.png │ ├── star3.png │ ├── star4.png │ ├── star5.png │ ├── discount.png │ ├── item-bg-1.png │ ├── item-bg-2.png │ ├── item-bg-3.png │ ├── item-bg-4.png │ ├── item-bg-5.png │ ├── item-bg-6.png │ ├── prestige.png │ ├── primogem.png │ ├── wx-reward.jpg │ ├── HanYiBlack.woff2 │ ├── arrow-left.png │ ├── arrow-right.png │ ├── btn-border.png │ ├── genshin-logo.png │ ├── group-qrcode.png │ ├── item-banner.png │ ├── sign-item-bg.png │ ├── title-banner.png │ ├── transformer.png │ ├── sign-item-signed.png │ └── sign-item-bg-today.png ├── utils │ ├── md5.ts │ ├── getServerNameByServer.ts │ ├── getCurrentRole.ts │ ├── sortGachaList.ts │ ├── getServerByUid.ts │ ├── mergeGachaList.ts │ ├── getDS.ts │ ├── utils.ts │ ├── getGameDir.ts │ ├── getGreetingMsg.ts │ ├── request.ts │ ├── updateLocalGachaData.ts │ └── verifyCookieAndGetGameRole.ts ├── render │ ├── hooks │ │ ├── useAuth.tsx │ │ ├── useLatest.ts │ │ ├── useMount.ts │ │ ├── useTimeout.ts │ │ ├── useInterval.ts │ │ ├── useApi.ts │ │ └── useNotice.tsx │ ├── utils │ │ ├── nativeApi.ts │ │ └── colors.less │ ├── auth │ │ ├── AuthContext.tsx │ │ ├── withAuth.tsx │ │ └── AuthProvider.tsx │ ├── components │ │ ├── Input │ │ │ ├── index.tsx │ │ │ └── index.less │ │ ├── RoleNumber │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── AbyssNumber │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── Alert │ │ │ ├── index.tsx │ │ │ └── index.less │ │ ├── NumberDescription │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── SelectButton │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── WinButton │ │ │ ├── index.tsx │ │ │ └── index.less │ │ ├── CircleButton │ │ │ ├── index.tsx │ │ │ └── index.less │ │ ├── Select │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── WinFrame │ │ │ ├── index.tsx │ │ │ └── index.less │ │ ├── Loading │ │ │ └── index.tsx │ │ ├── Button │ │ │ ├── index.tsx │ │ │ └── index.less │ │ ├── ItemCard │ │ │ ├── index.tsx │ │ │ └── index.less │ │ ├── BounceNumber │ │ │ └── index.tsx │ │ ├── WeaponCard │ │ │ ├── index.tsx │ │ │ └── index.less │ │ └── RoleCard │ │ │ ├── index.tsx │ │ │ └── index.less │ ├── pages │ │ ├── gacha │ │ │ ├── utils │ │ │ │ ├── getListByType.ts │ │ │ │ ├── transformGachaDataDate.ts │ │ │ │ ├── getLuckInfo.ts │ │ │ │ ├── getAverageTimes.ts │ │ │ │ ├── filterGachaList.ts │ │ │ │ ├── getMostInfo.ts │ │ │ │ ├── getPieData.ts │ │ │ │ └── getGachaStatistics.ts │ │ │ ├── Statistics │ │ │ │ ├── components │ │ │ │ │ ├── DateRange.tsx │ │ │ │ │ ├── ItemPie.tsx │ │ │ │ │ ├── StarPie.tsx │ │ │ │ │ └── TypePie.tsx │ │ │ │ └── index.less │ │ │ └── index.less │ │ ├── calendar │ │ │ ├── index.less │ │ │ ├── index.tsx │ │ │ ├── DailyMaterial │ │ │ │ └── index.less │ │ │ └── WeekMaterial │ │ │ │ └── index.less │ │ ├── setting │ │ │ ├── index.less │ │ │ ├── General │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ └── About │ │ │ │ └── index.less │ │ ├── statistic │ │ │ ├── RolesTab │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ │ ├── index.less │ │ │ └── SpiralAbyssTab │ │ │ │ └── index.less │ │ ├── role │ │ │ ├── API.md │ │ │ ├── constants.ts │ │ │ └── utils.ts │ │ ├── strategy │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── login │ │ │ └── index.less │ │ ├── sign │ │ │ └── index.less │ │ ├── portal │ │ │ ├── index.less │ │ │ └── index.tsx │ │ └── note │ │ │ └── Pie.tsx │ ├── index.html │ ├── index.tsx │ ├── index.less │ ├── router.tsx │ └── react-app-env.d.ts ├── main │ ├── IPC │ │ ├── getCurrentUser.ts │ │ ├── getGachaListByUrl.ts │ │ ├── clearData.ts │ │ ├── openWindow.ts │ │ ├── getGachaUrl.ts │ │ ├── openGame.ts │ │ ├── getLocalGachaData.ts │ │ ├── loginByBBS.ts │ │ └── handleGachaFile.ts │ ├── restoreSettings.ts │ ├── handleHotkeys.ts │ ├── handleUsers.ts │ ├── initStore.ts │ ├── index.ts │ ├── createMainWindow.ts │ └── initTray.ts ├── services │ ├── getHitokoto.ts │ ├── getGameRoleInfo.ts │ ├── getRepoData.ts │ ├── getUserRoleList.ts │ ├── getBBSSignData.ts │ ├── doBBSSign.ts │ ├── getBBSSignInfo.ts │ ├── getBBSSignActId.ts │ ├── getCalendarList.ts │ ├── getMonthInfo.ts │ ├── getGameRecordCard.ts │ ├── getDailyNotes.ts │ ├── getOwnedRoleList.ts │ ├── getCabinetRoleList.ts │ ├── getGameRoleCard.ts │ └── getSpiralAbyss.ts └── typings.d.ts ├── .npmrc ├── comment.template ├── webpack ├── main.config.js ├── rules.js └── renderer.config.js ├── tsconfig.json ├── README-en.md ├── forge.config.js ├── .eslintrc.js ├── .gitignore └── package.json /.node-version: -------------------------------------------------------------------------------- 1 | v16.18.0 -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .webpack/ 2 | out/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .webpack/ 3 | out/ -------------------------------------------------------------------------------- /src/assets/bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/bg.jpg -------------------------------------------------------------------------------- /src/assets/bbs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/bbs.png -------------------------------------------------------------------------------- /src/assets/cryo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/cryo.png -------------------------------------------------------------------------------- /src/assets/geo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/geo.png -------------------------------------------------------------------------------- /src/assets/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/home.png -------------------------------------------------------------------------------- /src/assets/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/icon.ico -------------------------------------------------------------------------------- /src/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/icon.png -------------------------------------------------------------------------------- /src/assets/lock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/lock.png -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/logo.png -------------------------------------------------------------------------------- /src/assets/mora.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/mora.png -------------------------------------------------------------------------------- /src/assets/pyro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/pyro.png -------------------------------------------------------------------------------- /src/assets/task.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/task.png -------------------------------------------------------------------------------- /src/assets/anemo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/anemo.png -------------------------------------------------------------------------------- /src/assets/cursor.cur: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/cursor.cur -------------------------------------------------------------------------------- /src/assets/dendro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/dendro.png -------------------------------------------------------------------------------- /src/assets/electro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/electro.png -------------------------------------------------------------------------------- /src/assets/hydro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/hydro.png -------------------------------------------------------------------------------- /src/assets/macicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/macicon.png -------------------------------------------------------------------------------- /src/assets/paimon.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/paimon.gif -------------------------------------------------------------------------------- /src/assets/paimon2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/paimon2.gif -------------------------------------------------------------------------------- /src/assets/resin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/resin.png -------------------------------------------------------------------------------- /src/assets/star1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/star1.png -------------------------------------------------------------------------------- /src/assets/star2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/star2.png -------------------------------------------------------------------------------- /src/assets/star3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/star3.png -------------------------------------------------------------------------------- /src/assets/star4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/star4.png -------------------------------------------------------------------------------- /src/assets/star5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/star5.png -------------------------------------------------------------------------------- /src/assets/discount.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/discount.png -------------------------------------------------------------------------------- /src/assets/item-bg-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/item-bg-1.png -------------------------------------------------------------------------------- /src/assets/item-bg-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/item-bg-2.png -------------------------------------------------------------------------------- /src/assets/item-bg-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/item-bg-3.png -------------------------------------------------------------------------------- /src/assets/item-bg-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/item-bg-4.png -------------------------------------------------------------------------------- /src/assets/item-bg-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/item-bg-5.png -------------------------------------------------------------------------------- /src/assets/item-bg-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/item-bg-6.png -------------------------------------------------------------------------------- /src/assets/prestige.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/prestige.png -------------------------------------------------------------------------------- /src/assets/primogem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/primogem.png -------------------------------------------------------------------------------- /src/assets/wx-reward.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/wx-reward.jpg -------------------------------------------------------------------------------- /src/assets/HanYiBlack.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/HanYiBlack.woff2 -------------------------------------------------------------------------------- /src/assets/arrow-left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/arrow-left.png -------------------------------------------------------------------------------- /src/assets/arrow-right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/arrow-right.png -------------------------------------------------------------------------------- /src/assets/btn-border.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/btn-border.png -------------------------------------------------------------------------------- /src/assets/genshin-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/genshin-logo.png -------------------------------------------------------------------------------- /src/assets/group-qrcode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/group-qrcode.png -------------------------------------------------------------------------------- /src/assets/item-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/item-banner.png -------------------------------------------------------------------------------- /src/assets/sign-item-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/sign-item-bg.png -------------------------------------------------------------------------------- /src/assets/title-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/title-banner.png -------------------------------------------------------------------------------- /src/assets/transformer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/transformer.png -------------------------------------------------------------------------------- /src/assets/sign-item-signed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/sign-item-signed.png -------------------------------------------------------------------------------- /src/assets/sign-item-bg-today.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikiboss/gs-helper/HEAD/src/assets/sign-item-bg-today.png -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmmirror.com/ 2 | disturl=https://npm.taobao.org/mirrors/node/ 3 | ELECTRON_MIRROR=https://npm.taobao.org/mirrors/electron/ 4 | -------------------------------------------------------------------------------- /comment.template: -------------------------------------------------------------------------------- 1 | 💥 feat: 添加了个很棒的功能 2 | 🐛 fix: 修复了一些 bug 3 | 💪 refactor: 代码进行了一些重构 4 | 💅 style: 不影响代码含义的修改,比如空格、格式化等 5 | 🔍 tests: 添加了一个测试用例 6 | 📝 docs: 更新了一下文档 7 | 🏰 chore: 对脚手架做了些更改 -------------------------------------------------------------------------------- /src/utils/md5.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'node:crypto' 2 | 3 | // MD5 加密算法 4 | export function md5(value: string) { 5 | return crypto.createHash('md5').update(value).digest('hex') 6 | } 7 | -------------------------------------------------------------------------------- /src/render/hooks/useAuth.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import AuthContext from '../auth/AuthContext' 4 | 5 | function useAuth() { 6 | return React.useContext(AuthContext) 7 | } 8 | 9 | export default useAuth 10 | -------------------------------------------------------------------------------- /src/render/utils/nativeApi.ts: -------------------------------------------------------------------------------- 1 | import { EXPOSED_API_FROM_ELECTRON } from '../../constants' 2 | 3 | import type { ElectronWindow } from '../../typings' 4 | 5 | const nativeApi = (window as ElectronWindow)[EXPOSED_API_FROM_ELECTRON] 6 | 7 | export default nativeApi 8 | -------------------------------------------------------------------------------- /src/utils/getServerNameByServer.ts: -------------------------------------------------------------------------------- 1 | export function getServerNameByServer(server: string) { 2 | if (server.startsWith('cn_gf')) { 3 | return '天空岛' 4 | } 5 | 6 | if (server.startsWith('cn_qd')) { 7 | return '世界树' 8 | } 9 | 10 | return '国际服' 11 | } 12 | -------------------------------------------------------------------------------- /src/main/IPC/getCurrentUser.ts: -------------------------------------------------------------------------------- 1 | import { store } from '..' 2 | 3 | /** 获取当前账号 */ 4 | export function getCurrentUser() { 5 | const currentUid = store.get('currentUid', '') 6 | 7 | if (!currentUid) { 8 | return null 9 | } 10 | 11 | return store.get('users').find((e) => e.uid === currentUid) || null 12 | } 13 | -------------------------------------------------------------------------------- /src/render/auth/AuthContext.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export interface AuthContextType { 4 | isLogin: boolean 5 | login: () => void 6 | logout: (uid?: string, isClear?: boolean) => void 7 | } 8 | 9 | const AuthContext = React.createContext(null) 10 | 11 | export default AuthContext 12 | -------------------------------------------------------------------------------- /src/render/components/Input/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import './index.less' 4 | 5 | export type InputProp = React.DetailedHTMLProps< 6 | React.InputHTMLAttributes, 7 | HTMLInputElement 8 | > 9 | 10 | export default function Input(props: InputProp) { 11 | return 12 | } 13 | -------------------------------------------------------------------------------- /src/render/hooks/useLatest.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react' 2 | 3 | /** 4 | * @from https://github.com/alibaba/hooks/blob/master/packages/hooks/src/useLatest/index.ts 5 | */ 6 | function useLatest(value: T) { 7 | const ref = useRef(value) 8 | ref.current = value 9 | 10 | return ref 11 | } 12 | 13 | export default useLatest 14 | -------------------------------------------------------------------------------- /src/utils/getCurrentRole.ts: -------------------------------------------------------------------------------- 1 | import type { GameRole } from '../typings' 2 | 3 | /** 获取默认角色(与米游社通行证设置的默认角色一致,如果要切换角色(区服),到米游社设置默认角色即可) */ 4 | export function getCurrentRole(roles: GameRole[]): null | GameRole { 5 | if (roles.length <= 0) { 6 | return null 7 | } 8 | 9 | return roles.find((e) => e.is_chosen) ?? roles[0] 10 | } 11 | -------------------------------------------------------------------------------- /src/services/getHitokoto.ts: -------------------------------------------------------------------------------- 1 | import { request } from '../utils/request' 2 | 3 | const HitokotoApi = 'https://v1.hitokoto.cn/?c=a&c=b&c=c&c=d&c=e&c=i' 4 | 5 | export async function getHitokoto() { 6 | try { 7 | const { status, data } = await request.get(HitokotoApi) 8 | 9 | return status === 200 ? `${data.hitokoto} ——「${data.from}」` : '出错啦,待会儿再试试吧' 10 | } catch { 11 | return '出错啦,待会儿再试试吧' 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/IPC/getGachaListByUrl.ts: -------------------------------------------------------------------------------- 1 | import { getGachaListByUrl } from '../../services/getGachaListByUrl' 2 | import { updateLocalGachaData } from '../../utils/updateLocalGachaData' 3 | 4 | /** 通过祈愿链接获取祈愿数据,并将改动更新到本地存档 */ 5 | export async function handleGetGachaListByUrl(url: string) { 6 | // 通过祈愿链接获取祈愿数据 7 | const gachaData = await getGachaListByUrl(url) 8 | // 将改动更新到本地存档 9 | return updateLocalGachaData(gachaData) 10 | } 11 | -------------------------------------------------------------------------------- /src/render/hooks/useMount.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | /** 4 | * @from https://github.com/alibaba/hooks/blob/master/packages/hooks/src/useMount/index.ts 5 | */ 6 | const useMount = (func: () => void, callback?: () => void) => { 7 | useEffect(() => { 8 | func() 9 | 10 | if (callback) { 11 | return callback 12 | } 13 | 14 | // eslint-disable-next-line react-hooks/exhaustive-deps 15 | }, []) 16 | } 17 | 18 | export default useMount 19 | -------------------------------------------------------------------------------- /src/main/restoreSettings.ts: -------------------------------------------------------------------------------- 1 | import { store } from '.' 2 | 3 | import type { AppData } from '../typings' 4 | import type { BrowserWindow } from 'electron' 5 | 6 | /** 恢复用户偏好设置 */ 7 | export function restoreSettings(win: BrowserWindow) { 8 | const settings = store.get('settings') as AppData['settings'] 9 | 10 | // console.log(settings); 11 | const { alwaysOnTop } = settings 12 | 13 | // win.setAlwaysOnTop(isDev || alwaysOnTop) 14 | 15 | win.setAlwaysOnTop(alwaysOnTop) 16 | } 17 | -------------------------------------------------------------------------------- /src/services/getGameRoleInfo.ts: -------------------------------------------------------------------------------- 1 | import { getUserRolesByCookie } from './getUserRoleList' 2 | import { getCurrentUser } from '../main/IPC/getCurrentUser' 3 | import { getCurrentRole } from '../utils/getCurrentRole' 4 | 5 | export async function getGameRoleInfo() { 6 | const user = getCurrentUser() 7 | 8 | if (!user) { 9 | return null 10 | } 11 | 12 | const { data } = await getUserRolesByCookie(user.cookie) 13 | 14 | return getCurrentRole(data?.list ?? []) || null 15 | } 16 | -------------------------------------------------------------------------------- /webpack/main.config.js: -------------------------------------------------------------------------------- 1 | const rules = require('./rules') 2 | 3 | module.exports = { 4 | entry: './src/main/index.ts', 5 | module: { 6 | rules: [ 7 | ...rules, 8 | { 9 | test: /\.(svg|jpg|jpeg|png|ico|gif)$/i, 10 | type: 'asset/resource', 11 | generator: { 12 | filename: 'assets/[hash][ext][query]' 13 | } 14 | } 15 | ] 16 | }, 17 | resolve: { 18 | extensions: ['.js', '.ts', '.jsx', '.tsx', '.css', '.json'] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/sortGachaList.ts: -------------------------------------------------------------------------------- 1 | import type { GachaData } from '../typings' 2 | 3 | // 通过 id (时间)进行列表排序 4 | export function sortGachaList(list: GachaData['list']) { 5 | list.sort((p, n) => { 6 | // 前九位代表一个时间段,每个小时过六分 7 | const time = Number(p.id.slice(0, 9)) - Number(n.id.slice(0, 9)) 8 | // 后面的数字代表抽卡次序,每个时间段内依次递增 9 | const order = Number(p.id.slice(9)) - Number(n.id.slice(9)) 10 | // 先按照时间段排序,如果时间段相同,再按照抽卡次序 11 | return time === 0 ? order : time 12 | }) 13 | return list 14 | } 15 | -------------------------------------------------------------------------------- /src/render/pages/gacha/utils/getListByType.ts: -------------------------------------------------------------------------------- 1 | import { GachaTypeMap } from './filterGachaList' 2 | 3 | import type { GachaData, GachaType } from '../../../../typings' 4 | 5 | function getListByType(list: GachaData['list'], type: GachaType) { 6 | return list.filter((e) => { 7 | const target = GachaTypeMap[type] 8 | if (Array.isArray(target)) { 9 | return target.includes(e.uigf_gacha_type) 10 | } 11 | return target === e.uigf_gacha_type 12 | }) 13 | } 14 | 15 | export default getListByType 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "module": "CommonJS", 5 | "target": "ESNEXT", 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "noImplicitAny": true, 9 | "sourceMap": true, 10 | "outDir": "dist", 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "downlevelIteration": true, 14 | "jsx": "react", 15 | "paths": { 16 | "*": ["./node_modules/*"] 17 | } 18 | }, 19 | "include": ["./src/**/*"] 20 | } 21 | -------------------------------------------------------------------------------- /src/render/auth/withAuth.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useLocation } from 'react-router-dom' 3 | 4 | import useAuth from '../hooks/useAuth' 5 | import Login from '../pages/login' 6 | 7 | function withAuth(Component: React.ComponentType) { 8 | return function Auth() { 9 | const { isLogin } = useAuth() 10 | const location = useLocation() 11 | 12 | if (isLogin) { 13 | return 14 | } 15 | 16 | return 17 | } 18 | } 19 | 20 | export default withAuth 21 | -------------------------------------------------------------------------------- /src/render/components/RoleNumber/index.less: -------------------------------------------------------------------------------- 1 | @import '../../utils/colors.less'; 2 | @import '../../utils/utils.less'; 3 | 4 | .wrapper { 5 | .flex-center-column(); 6 | 7 | & > div:first-child { 8 | display: flex; 9 | 10 | & > div { 11 | margin-top: 4px; 12 | margin-right: 8px; 13 | } 14 | } 15 | 16 | & > span { 17 | color: @color-second; 18 | font-size: 12px; 19 | margin-top: 4px; 20 | text-align: center; 21 | } 22 | 23 | & img { 24 | height: 24px; 25 | width: $height; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/render/components/AbyssNumber/index.less: -------------------------------------------------------------------------------- 1 | @import '../../utils/colors.less'; 2 | @import '../../utils/utils.less'; 3 | 4 | .wrapper { 5 | .flex-center-column(); 6 | 7 | & > span { 8 | color: @color-second; 9 | font-size: 12px; 10 | margin-top: 4px; 11 | text-align: center; 12 | } 13 | 14 | .horizontal { 15 | .flex-center(); 16 | 17 | font-family: arial; 18 | font-size: 20px; 19 | font-weight: bold; 20 | 21 | & > span { 22 | align-self: flex-end; 23 | font-size: 12px; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/render/components/Alert/index.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import React from 'react' 3 | 4 | import styles from './index.less' 5 | 6 | export interface AlertProp { 7 | visible: boolean 8 | message: string 9 | type: 'info' | 'warning' | 'success' | 'failed' 10 | } 11 | 12 | export default function Alert(props: AlertProp) { 13 | const { visible, type, message } = props 14 | const visibleClass = visible ? styles.show : styles.hide 15 | 16 | return
{message}
17 | } 18 | -------------------------------------------------------------------------------- /src/render/pages/gacha/utils/transformGachaDataDate.ts: -------------------------------------------------------------------------------- 1 | import type { GachaData } from '../../../../typings' 2 | 3 | export default function transformGachaDataDate(list: GachaData['list']) { 4 | const dateMap = new Map() 5 | 6 | for (const item of list) { 7 | const date = item.time.slice(0, 10) 8 | dateMap.set(date, dateMap.has(date) ? dateMap.get(date) + 1 : 1) 9 | } 10 | 11 | const res = [] as { day: string; value: number }[] 12 | 13 | dateMap.forEach((v, k) => res.push({ day: k, value: v })) 14 | 15 | return res 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/getServerByUid.ts: -------------------------------------------------------------------------------- 1 | export const Servers = [ 2 | 'cn_gf01', // 1 开头,国区官服-天空岛 3 | 'cn_gf01', // 2 开头,国区官服-天空岛 4 | 'cn_gf01', // 3 开头,国区官服-天空岛 5 | 'cn_gf01', // 4 开头,国区官服-天空岛 6 | 'cn_qd01', // 5 开头,国区渠道服-世界树 7 | 'os_usa', // 6 开头,美服 8 | 'os_euro', // 7 开头,欧服 9 | 'os_aisa', // 8 开头,亚服 10 | 'os_cht' // 9 开头,港澳台服 11 | ] as const 12 | 13 | export function getServerByUid(uid: string) { 14 | // 通过正则表达式过滤掉无效的 UID,合法的 UID 须是以数字 1-9 开头 15 | if (!/^[1-9]$/.test(uid[0])) { 16 | return '' 17 | } 18 | 19 | return Servers[Number(uid[0]) - 1] 20 | } 21 | -------------------------------------------------------------------------------- /src/main/handleHotkeys.ts: -------------------------------------------------------------------------------- 1 | import { app, globalShortcut } from 'electron' 2 | 3 | import type { BrowserWindow } from 'electron' 4 | 5 | // 一系列热键操作函数 6 | 7 | /** 注册全局热键 */ 8 | export function registerHotkey(win: BrowserWindow) { 9 | // 注册老板键 10 | globalShortcut.register('CommandOrControl+Q', () => 11 | win.isVisible() && !win.isMinimized() ? win.hide() : win.show() 12 | ) 13 | 14 | // 注册退出键 15 | globalShortcut.register('Alt+F4', () => app.quit()) 16 | } 17 | 18 | /** 取消注册所有全局热键 */ 19 | export function unregisterHotkey() { 20 | globalShortcut.unregisterAll() 21 | } 22 | -------------------------------------------------------------------------------- /webpack/rules.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | test: /native_modules\/.+\.node$/, 4 | use: 'node-loader' 5 | }, 6 | { 7 | test: /\.(m?js|node)$/, 8 | parser: { amd: false }, 9 | use: { 10 | loader: '@vercel/webpack-asset-relocator-loader', 11 | options: { 12 | outputAssetBase: 'native_modules' 13 | } 14 | } 15 | }, 16 | { 17 | test: /\.tsx?$/, 18 | exclude: /(node_modules|\.webpack)/, 19 | use: { 20 | loader: 'ts-loader', 21 | options: { 22 | transpileOnly: true 23 | } 24 | } 25 | } 26 | ] 27 | -------------------------------------------------------------------------------- /src/render/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 原神助手 - Genshin Helper 7 | 18 | 19 | 20 |
loading...
21 | 22 | 23 | -------------------------------------------------------------------------------- /src/render/pages/calendar/index.less: -------------------------------------------------------------------------------- 1 | @import '../../utils/colors.less'; 2 | @import '../../utils/utils.less'; 3 | 4 | .container { 5 | .ani-show-right(); 6 | .flex-center-x-column(); 7 | 8 | flex: 1; 9 | position: relative; 10 | 11 | .top { 12 | // .ani-show-top(); 13 | .flex-center(); 14 | 15 | width: 100%; 16 | height: 82px; 17 | background-color: @color-bg; 18 | position: relative; 19 | 20 | .title { 21 | font-size: 24px; 22 | } 23 | } 24 | 25 | .back-btn { 26 | left: 20px; 27 | position: absolute; 28 | top: 20px; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/services/getRepoData.ts: -------------------------------------------------------------------------------- 1 | import { API_DATA, API_DATA_BAK } from '../constants' 2 | import { request } from '../utils/request' 3 | 4 | export async function getRepoData(filename: string): Promise { 5 | try { 6 | const api = `${API_DATA}/${filename}` 7 | const { status, data } = await request.get(api) 8 | return status === 200 ? data : null 9 | } catch { 10 | try { 11 | const api = `${API_DATA_BAK}/${filename}` 12 | const { status, data } = await request.get(api) 13 | return status === 200 ? data : null 14 | } catch { 15 | return null 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/render/components/NumberDescription/index.less: -------------------------------------------------------------------------------- 1 | @import '../../utils/colors.less'; 2 | @import '../../utils/utils.less'; 3 | 4 | .wrapper { 5 | .flex-center-column(); 6 | 7 | & > span { 8 | color: @color-second; 9 | font-size: 12px; 10 | margin-top: 4px; 11 | text-align: center; 12 | } 13 | 14 | .horizontal { 15 | .flex-center(); 16 | 17 | font-family: arial; 18 | font-size: 20px; 19 | font-weight: bold; 20 | 21 | .full-num { 22 | color: @color-second; 23 | } 24 | 25 | & > span { 26 | align-self: flex-end; 27 | font-size: 12px; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/mergeGachaList.ts: -------------------------------------------------------------------------------- 1 | import { sortGachaList } from './sortGachaList' 2 | 3 | import type { GachaData } from '../typings' 4 | 5 | // 新旧祈愿数据列表合并算法 6 | export function mergeGachaList(pre: GachaData['list'], list: GachaData['list']) { 7 | // 先把新旧数据合并到同一个数组 8 | let results = pre.concat(list) 9 | // 创建一个 Set,它的值具有唯一性 10 | const ids = new Set() 11 | 12 | // 通过 id 过滤掉重复的单条数据 13 | results = results.filter((e) => { 14 | if (ids.has(e.id)) { 15 | return false 16 | } 17 | return ids.add(e.id) 18 | }) 19 | 20 | // 数据排序 21 | results = sortGachaList(results) 22 | 23 | // 返回合并后的数据 24 | return results 25 | } 26 | -------------------------------------------------------------------------------- /src/render/pages/setting/index.less: -------------------------------------------------------------------------------- 1 | @import '../../utils/colors.less'; 2 | @import '../../utils/utils.less'; 3 | 4 | .container { 5 | .ani-show-right(); 6 | .flex-column(); 7 | 8 | height: 100%; 9 | position: relative; 10 | 11 | .top { 12 | .ani-show-top(); 13 | .flex-center(); 14 | 15 | height: 82px; 16 | background-color: @color-bg; 17 | position: relative; 18 | } 19 | 20 | .content { 21 | .flex-center-column(); 22 | 23 | height: 100%; 24 | padding: 0px 20px 20px 20px; 25 | } 26 | 27 | .back-btn { 28 | left: 20px; 29 | position: absolute; 30 | top: 20px; 31 | z-index: 10; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/render/pages/setting/General/index.less: -------------------------------------------------------------------------------- 1 | @import '../../../utils/colors.less'; 2 | @import '../../../utils/utils.less'; 3 | 4 | .main { 5 | .ani-show-right(); 6 | .flex-center-column(); 7 | 8 | width: 100%; 9 | height: 100%; 10 | 11 | .welcome { 12 | font-size: 20px; 13 | height: 394px; 14 | line-height: $height; 15 | } 16 | 17 | .clearCache { 18 | .flex-column(); 19 | 20 | align-self: flex-start; 21 | 22 | & > div:first-child { 23 | width: 80px; 24 | } 25 | 26 | & > span { 27 | color: @color-second; 28 | font-size: 14px; 29 | margin-top: 8px; 30 | color: @color-failed; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/render/components/Alert/index.less: -------------------------------------------------------------------------------- 1 | @import '../../utils/colors.less'; 2 | @import '../../utils/utils.less'; 3 | 4 | .alert { 5 | .transition-slow(); 6 | .flex-center(); 7 | 8 | color: @color-white; 9 | font-size: 20px; 10 | height: 42px; 11 | left: 0; 12 | position: absolute; 13 | width: 100vw; 14 | z-index: 1000; 15 | } 16 | 17 | .info { 18 | background-color: @color-info; 19 | } 20 | 21 | .warning { 22 | background-color: @color-warning; 23 | } 24 | 25 | .success { 26 | background-color: @color-success; 27 | } 28 | 29 | .failed { 30 | background-color: @color-failed; 31 | } 32 | 33 | .hide { 34 | top: 100vh; 35 | } 36 | 37 | .show { 38 | top: calc(100vh - 42px); 39 | } 40 | -------------------------------------------------------------------------------- /src/render/components/SelectButton/index.less: -------------------------------------------------------------------------------- 1 | @import '../../utils/colors.less'; 2 | @import '../../utils/utils.less'; 3 | 4 | .wrapper { 5 | .flex(); 6 | 7 | border-radius: 6px; 8 | overflow: hidden; 9 | 10 | & > div { 11 | .flex-center(); 12 | .transition(); 13 | 14 | background-color: @color-bg-deep; 15 | font-size: 14px; 16 | padding: 8px 12px; 17 | 18 | &:hover { 19 | background-color: @color-bg-second; 20 | } 21 | } 22 | 23 | .selected { 24 | background-color: @color-accent; 25 | color: @color-white; 26 | 27 | &:hover { 28 | background-color: @color-accent; 29 | } 30 | } 31 | } 32 | 33 | .vertical { 34 | flex-direction: column; 35 | } 36 | -------------------------------------------------------------------------------- /src/render/pages/statistic/RolesTab/index.less: -------------------------------------------------------------------------------- 1 | @import '../../../utils/colors.less'; 2 | @import '../../../utils/utils.less'; 3 | 4 | .roles { 5 | .flex-center-column(); 6 | 7 | .role-table { 8 | .ani-show-top(); 9 | .flex(); 10 | 11 | flex-wrap: wrap; 12 | flex: 1; 13 | justify-content: space-between; 14 | margin-top: 40px; 15 | overflow: auto; 16 | padding: 20px; 17 | width: 890px; 18 | } 19 | 20 | .btn { 21 | .ani-show-bottom(); 22 | 23 | margin-top: 60px; 24 | } 25 | 26 | .tip { 27 | .ani-show-bottom(); 28 | 29 | bottom: 16px; 30 | color: @color-second; 31 | font-size: 14px; 32 | left: 28px; 33 | position: absolute; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/render/components/RoleNumber/index.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import React from 'react' 3 | 4 | import styles from './index.less' 5 | import BounceNumber from '../BounceNumber' 6 | 7 | interface RoleNumberProp { 8 | className?: string 9 | avatar: string 10 | value: number 11 | description: string 12 | } 13 | 14 | export default function RoleNumber(props: RoleNumberProp) { 15 | const { value, avatar, className, description } = props 16 | 17 | return ( 18 |
19 |
20 | {avatar && } 21 | 22 |
23 | {description} 24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/main/IPC/clearData.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron' 2 | import fs from 'fs-extra' 3 | import path from 'node:path' 4 | 5 | import { DefaultAppData } from '../initStore' 6 | 7 | export async function clearData() { 8 | // 获取当前的软件目录 9 | const AppPath = app.getPath('userData') 10 | 11 | // 获取配置文件路径 12 | const GachaDataDirPath = path.join(AppPath, 'config.json') 13 | 14 | // 配置文件不存在则返回 15 | if (!fs.existsSync(GachaDataDirPath)) { 16 | return true 17 | } 18 | 19 | // 当配置文件存在 20 | try { 21 | // 尝试删除配置文件 22 | fs.unlinkSync(GachaDataDirPath) 23 | 24 | // 写入默认配置 25 | fs.writeJsonSync(GachaDataDirPath, DefaultAppData, { spaces: 2 }) 26 | 27 | return true 28 | } catch { 29 | return false 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/render/components/NumberDescription/index.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import React from 'react' 3 | 4 | import styles from './index.less' 5 | import BounceNumber from '../BounceNumber' 6 | 7 | interface NumberDescriptionProp { 8 | className?: string 9 | value: number 10 | sub?: string 11 | description: string 12 | } 13 | 14 | export default function NumberDescription(props: NumberDescriptionProp) { 15 | const { value, sub, description, className } = props 16 | 17 | return ( 18 |
19 |
20 | 21 | {sub} 22 |
23 | {description} 24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /README-en.md: -------------------------------------------------------------------------------- 1 | ![genshin-helper](https://socialify.git.ci/Vikiboss/genshin-helper/image?description=1&font=Source%20Code%20Pro&forks=1&issues=1&language=1&logo=https%3A%2F%2Fgithub.com%2FVikiboss%2Fgenshin-helper%2Fblob%2Fmain%2Fsrc%2Fassets%2Ficon.png%3Fraw%3Dtrue&owner=1&pattern=Circuit%20Board&pulls=1&stargazers=1&theme=Light) 2 | 3 | [中文](README.md) | English 4 | 5 | > 🚧 Please note: This project is still under development. 6 | 7 | ### Introduction 8 | 9 | A small open source tool written for _Genshin Impact_ player, based on [Electron](https://www.electronjs.org/) and [React](https://reactjs.org/). 10 | 11 | > [_Genshin Impact_](https://genshin.hoyoverse.com/en/) is an open world adventure game produced by [miHoYo](https://www.mihoyo.com/en/). 12 | 13 | ### License 14 | 15 | - [GPL-3.0](LICENSE) 16 | -------------------------------------------------------------------------------- /src/render/components/AbyssNumber/index.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import React from 'react' 3 | 4 | import styles from './index.less' 5 | import BounceNumber from '../BounceNumber' 6 | 7 | interface AbyssNumberProp { 8 | className?: string 9 | values: [number, number] 10 | description?: string 11 | } 12 | 13 | export default function AbyssNumber(props: AbyssNumberProp) { 14 | const { values, className, description = '深境螺旋' } = props 15 | 16 | return ( 17 |
18 |
19 | 20 |
-
21 | 22 |
23 | {description} 24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/render/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | 4 | import AuthProvider from './auth/AuthProvider' 5 | import WinFrame from './components/WinFrame' 6 | import './index.less' 7 | import AppRouter from './router' 8 | import nativeApi from './utils/nativeApi' 9 | 10 | const root = createRoot(document.getElementById('app')) 11 | 12 | const render = async () => { 13 | const { zhName, version, isBeta } = await nativeApi.getAppInfo() 14 | const isLogin = Boolean(await nativeApi.getStoreKey('currentUid')) 15 | 16 | root.render( 17 | 18 | 19 | 20 | 21 | 22 | ) 23 | } 24 | 25 | render() 26 | -------------------------------------------------------------------------------- /src/services/getUserRoleList.ts: -------------------------------------------------------------------------------- 1 | import { API_TAKUMI, GAME_BIZ, LINK_BBS_REFERER } from '../constants' 2 | import { request } from '../utils/request' 3 | 4 | import type { BaseRes, GameRolesData } from '../typings' 5 | 6 | /** 通过 Cookie 获取绑定的角色信息列表 */ 7 | export async function getUserRolesByCookie(cookie: string) { 8 | const url = `${API_TAKUMI}/binding/api/getUserGameRolesByCookie` 9 | const params = { game_biz: GAME_BIZ } 10 | const headers = { referer: LINK_BBS_REFERER, cookie } 11 | const config = { params, headers } 12 | 13 | const { status, data } = await request.get>(url, config) 14 | 15 | // { data: null, message: '登录失效,请重新登录', retcode: -100 } 16 | if (status !== 200 || data?.retcode !== 0) { 17 | console.log('getUserRolesByCookie: ', data) 18 | } 19 | 20 | return data 21 | } 22 | -------------------------------------------------------------------------------- /src/render/components/Input/index.less: -------------------------------------------------------------------------------- 1 | @import '../../utils/colors.less'; 2 | @import '../../utils/utils.less'; 3 | 4 | input::-webkit-outer-spin-button, 5 | input::-webkit-inner-spin-button { 6 | appearance: none; 7 | -webkit-appearance: none; 8 | } 9 | 10 | input[type='number'] { 11 | appearance: none; 12 | -moz-appearance: textfield; 13 | } 14 | 15 | input { 16 | .transition(); 17 | 18 | background-color: transparent; 19 | border: 2px solid transparent; 20 | border-radius: 6px; 21 | color: @color-primary; 22 | font-size: 18px; 23 | font-weight: bold; 24 | height: 28px; 25 | line-height: 28px; 26 | outline: none; 27 | padding: 1px 2px; 28 | 29 | &:hover { 30 | border-color: @color-second; 31 | } 32 | 33 | &:focus { 34 | border-color: @color-second; 35 | } 36 | 37 | &::placeholder { 38 | color: @color-second; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/render/pages/gacha/utils/getLuckInfo.ts: -------------------------------------------------------------------------------- 1 | import getListByType from './getListByType' 2 | import { NormalItemList } from '../../../../constants' 3 | 4 | import type { GachaData } from '../../../../typings' 5 | 6 | export default function getLuckInfo(gacha: GachaData) { 7 | let count = 0 8 | let miss = 0 9 | 10 | // 0 为 小保底,1 为大保底 11 | let status = 0 12 | 13 | const ActivityList = getListByType(gacha.list, 'activity') 14 | 15 | for (const e of ActivityList) { 16 | if (e.rank_type === '5') { 17 | if (status === 1) { 18 | status = 0 19 | } else { 20 | const isMiss = NormalItemList.includes(e.name) 21 | count++ 22 | 23 | if (isMiss) { 24 | miss++ 25 | } 26 | 27 | status = isMiss ? 1 : 0 28 | } 29 | } 30 | } 31 | return { count, miss, rate: ((100 * miss) / (count || 1)).toFixed(2) } 32 | } 33 | -------------------------------------------------------------------------------- /src/render/pages/gacha/utils/getAverageTimes.ts: -------------------------------------------------------------------------------- 1 | import { ItemTypeMap } from './filterGachaList' 2 | import getListByType from './getListByType' 3 | import { NormalItemList } from '../../../../constants' 4 | 5 | import type { GachaData, GachaItemType } from '../../../../typings' 6 | 7 | export default function getAverageTimes(gacha: GachaData, type: GachaItemType, isLimit = true) { 8 | const list = getListByType(gacha.list, type === 'role' ? 'activity' : 'weapon') 9 | const i5 = [] 10 | 11 | for (const [i, e] of list.entries()) { 12 | const limit = isLimit ? !NormalItemList.includes(e.name) : true 13 | const isTypeRight = e.item_type === ItemTypeMap[type] 14 | 15 | if (e.rank_type === '5' && isTypeRight && limit) { 16 | i5.push(i + 1) 17 | } 18 | } 19 | 20 | const times = i5.length ? i5[i5.length - 1] / i5.length : 0 21 | 22 | return Math.round(times) 23 | } 24 | -------------------------------------------------------------------------------- /src/render/components/WinButton/index.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import React from 'react' 3 | import { FaExpandArrowsAlt, FaMinus } from 'react-icons/fa' 4 | 5 | import styles from './index.less' 6 | 7 | import type { IconType } from 'react-icons' 8 | 9 | export interface WinButtonProp { 10 | type: 'close' | 'minimize' 11 | className?: string 12 | onClick?: () => void 13 | } 14 | 15 | const TYPE_MAP: Record = { 16 | close: FaExpandArrowsAlt, 17 | minimize: FaMinus 18 | } 19 | 20 | export default function WinButton(props: WinButtonProp) { 21 | const { onClick, className = '', type } = props 22 | 23 | const Icon = TYPE_MAP[type] 24 | 25 | const divClass = cn(styles.btn, styles.size, className) 26 | 27 | return ( 28 |
29 | 30 |
31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/render/index.less: -------------------------------------------------------------------------------- 1 | @import './utils/colors.less'; 2 | @import './utils/utils.less'; 3 | 4 | @font-face { 5 | font-family: HanYiBlack; 6 | src: url('../assets/HanYiBlack.woff2'); 7 | } 8 | 9 | :root { 10 | -webkit-user-select: none; 11 | color: @color-primary; 12 | font-family: HanYiBlack; 13 | user-select: none; 14 | } 15 | 16 | body { 17 | font-size: 16px; 18 | overflow: hidden; 19 | cursor: url('../assets/cursor.cur'), default; 20 | } 21 | 22 | input::selection { 23 | color: @color-white; 24 | background-color: @color-accent; 25 | } 26 | 27 | img { 28 | -webkit-user-drag: none; 29 | } 30 | 31 | a { 32 | .transition(); 33 | 34 | color: @color-accent; 35 | outline: none; 36 | text-decoration: none; 37 | 38 | &:hover { 39 | color: @color-accent-light; 40 | cursor: pointer; 41 | } 42 | 43 | &:active { 44 | color: @color-second; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/render/components/CircleButton/index.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames' 2 | import React from 'react' 3 | 4 | import styles from './index.less' 5 | 6 | import type { IconType } from 'react-icons' 7 | 8 | export interface CircleButtonProp { 9 | Icon: IconType 10 | tip?: string 11 | size?: 'small' | 'middle' | 'large' 12 | className?: string 13 | onClick?: (...args: any[]) => any 14 | } 15 | 16 | const SIZE_MAP = { 17 | small: 16, 18 | middle: 24, 19 | large: 42 20 | } 21 | 22 | export default function CircleButton(props: CircleButtonProp) { 23 | const { onClick, className = '', size = 'small', Icon, tip = '' } = props 24 | 25 | const divClass = classnames(styles.btn, styles[size], className) 26 | 27 | return ( 28 |
29 | 30 |
31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/render/components/Select/index.less: -------------------------------------------------------------------------------- 1 | @import '../../utils/colors.less'; 2 | @import '../../utils/utils.less'; 3 | 4 | .wrapper { 5 | .flex-center-y(); 6 | 7 | &:hover { 8 | select { 9 | border: 2px solid @color-second; 10 | } 11 | 12 | label, 13 | select { 14 | color: @color-primary; 15 | } 16 | } 17 | 18 | label { 19 | .transition-slow(); 20 | 21 | color: @color-primary; 22 | margin-right: 6px; 23 | } 24 | 25 | select { 26 | .transition-slow(); 27 | 28 | color: @color-primary; 29 | background-color: transparent; 30 | border: 2px solid transparent; 31 | border-radius: 6px; 32 | font-size: 18px; 33 | font-weight: bold; 34 | height: 32px; 35 | line-height: 32px; 36 | outline: none; 37 | padding: 3px; 38 | width: 124px; 39 | 40 | option { 41 | font-weight: bold; 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/services/getBBSSignData.ts: -------------------------------------------------------------------------------- 1 | import { getBBSSignActId } from './getBBSSignActId' 2 | import { API_TAKUMI, LINK_BBS_REFERER } from '../constants' 3 | import { request } from '../utils/request' 4 | 5 | import type { BaseRes } from '../typings' 6 | 7 | export interface SignItem { 8 | cnt: number 9 | icon: string 10 | name: string 11 | } 12 | 13 | export interface SignData { 14 | awards: SignItem[] 15 | month: number 16 | resign: boolean 17 | } 18 | 19 | export async function getBBSSignData() { 20 | const actId = getBBSSignActId() 21 | 22 | const url = `${API_TAKUMI}/event/bbs_sign_reward/home` 23 | const config = { params: { act_id: actId }, headers: { referer: LINK_BBS_REFERER } } 24 | 25 | const { status, data } = await request.get>(url, config) 26 | 27 | if (status !== 200 || data?.retcode !== 0) { 28 | console.log('getBBSSignData: ', data) 29 | } 30 | 31 | return data 32 | } 33 | -------------------------------------------------------------------------------- /src/render/components/WinButton/index.less: -------------------------------------------------------------------------------- 1 | @import '../../utils/colors.less'; 2 | @import '../../utils/utils.less'; 3 | 4 | @win-bar-bg-color: #454d5c; 5 | @win-bar-bg-line-color: #515a6a; 6 | @win-bar-title-color: #ece5d8; 7 | 8 | @size: 24px; 9 | 10 | @wbtn-actiove-bg: #9b947f; 11 | @wbtn-round-line: #878d8d; 12 | 13 | .btn { 14 | .transition(); 15 | .flex-center(); 16 | 17 | background-color: @win-bar-title-color; 18 | overflow: hidden; 19 | 20 | .icon { 21 | .transition(); 22 | 23 | color: @win-bar-bg-color; 24 | } 25 | 26 | &:hover { 27 | border-color: @color-bg; 28 | } 29 | 30 | &:active { 31 | background-color: @wbtn-actiove-bg; 32 | background-color: @wbtn-actiove-bg; 33 | border-color: @wbtn-actiove-bg; 34 | 35 | .icon { 36 | color: @color-bg; 37 | } 38 | } 39 | } 40 | 41 | .size { 42 | border-radius: calc((@size + 6px) / 2); 43 | border: 3px solid @wbtn-round-line; 44 | width: @size; 45 | height: @size; 46 | } 47 | -------------------------------------------------------------------------------- /src/render/components/WinFrame/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import styles from './index.less' 4 | import icon from '../../../assets/icon.png' 5 | import nativeApi from '../../utils/nativeApi' 6 | import WinButton from '../WinButton' 7 | 8 | export interface WinFrameProp { 9 | title?: string 10 | children?: JSX.Element 11 | } 12 | 13 | export default function WinFrame(props: WinFrameProp) { 14 | const { title = '' } = props 15 | return ( 16 |
17 |
18 | 19 |
{title}
20 |
21 | 22 | 23 |
24 |
25 |
{props.children}
26 |
27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/render/hooks/useTimeout.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react' 2 | 3 | import useLatest from './useLatest' 4 | 5 | /** 6 | * @from https://github.com/alibaba/hooks/blob/master/packages/hooks/src/useTimeout/index.ts 7 | */ 8 | function useTimeout(fn: () => void, delay?: number) { 9 | const fnRef = useLatest(fn) 10 | const timerRef = useRef(null) 11 | 12 | useEffect(() => { 13 | if (typeof delay !== 'number' || delay < 0) { 14 | return 15 | } 16 | 17 | timerRef.current = setTimeout(() => { 18 | fnRef.current() 19 | }, delay) 20 | 21 | return () => { 22 | if (timerRef.current) { 23 | clearTimeout(timerRef.current) 24 | } 25 | } 26 | // eslint-disable-next-line react-hooks/exhaustive-deps 27 | }, [delay]) 28 | 29 | const clear = useCallback(() => { 30 | if (timerRef.current) { 31 | clearTimeout(timerRef.current) 32 | } 33 | }, []) 34 | 35 | return clear 36 | } 37 | 38 | export default useTimeout 39 | -------------------------------------------------------------------------------- /webpack/renderer.config.js: -------------------------------------------------------------------------------- 1 | const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin') 2 | 3 | const rules = require('./rules') 4 | 5 | module.exports = { 6 | module: { 7 | rules: [ 8 | ...rules, 9 | { 10 | test: /\.(svg|jpg|jpeg|png|ico|gif|woff2)$/i, 11 | type: 'asset/inline' 12 | }, 13 | { 14 | test: /\.less$/i, 15 | use: [ 16 | 'style-loader', 17 | { 18 | loader: 'css-loader', 19 | options: { 20 | modules: { 21 | localIdentName: '[name]__[local]--[hash:base64:5]', 22 | exportLocalsConvention: 'camelCase' 23 | } 24 | } 25 | }, 26 | 'less-loader' 27 | ] 28 | }, 29 | { 30 | test: /\.css$/i, 31 | use: ['style-loader', 'css-loader'] 32 | } 33 | ] 34 | }, 35 | plugins: [new ForkTsCheckerWebpackPlugin()], 36 | resolve: { 37 | extensions: ['.js', '.ts', '.jsx', '.tsx', '.css'] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/getDS.ts: -------------------------------------------------------------------------------- 1 | import { md5 } from './md5' 2 | import { qs, random } from './utils' 3 | 4 | // 获取只包含数字与字母的指定位数的随机字符串 5 | function getRandomStr(n: number) { 6 | const chars = 'abcdefghijklmnopqrstuvwxyz0123456789' 7 | let str = '' 8 | for (let i = 0; i < n; i++) str += chars.charAt(random(0, chars.length - 1)) 9 | return str 10 | } 11 | 12 | // ver 2.34.1 13 | export function getDS(query = '', body = '') { 14 | const params = { 15 | salt: 'xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs', 16 | t: String(Math.floor(Date.now() / 1000)), 17 | r: String(random(100000, 1000000)), 18 | b: body, 19 | q: query 20 | } 21 | const DS = `${params.t},${params.r},${md5(qs(params))}` 22 | console.log('getDS: ', DS) 23 | return DS 24 | } 25 | 26 | // ver 2.37.1 27 | export function getSignDS() { 28 | const params = { 29 | salt: 'Qqx8cyv7kuyD8fTw11SmvXSFHp7iZD29', 30 | t: String(Math.floor(Date.now() / 1000)), 31 | r: getRandomStr(6) 32 | } 33 | const DS = `${params.t},${params.r},${md5(qs(params))}` 34 | console.log('getSignDS: ', DS) 35 | return DS 36 | } 37 | -------------------------------------------------------------------------------- /src/render/components/Loading/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import paimon from '../../../assets/paimon.gif' 4 | import paimon2 from '../../../assets/paimon2.gif' 5 | 6 | export interface LoadingProp { 7 | text?: string 8 | style?: React.CSSProperties 9 | isEmpty?: boolean 10 | className?: React.HTMLAttributes['className'] 11 | } 12 | 13 | export default function Loading(props: LoadingProp) { 14 | const { isEmpty = false, className, style = {} } = props 15 | 16 | const text = isEmpty ? '没有内容' : '小派蒙正在努力加载中...' 17 | 18 | const divStyle: React.CSSProperties = { 19 | ...style, 20 | flex: 1, 21 | display: 'flex', 22 | flexDirection: 'column', 23 | alignItems: 'center', 24 | justifyContent: 'center', 25 | alignSelf: 'center', 26 | justifySelf: 'center' 27 | } 28 | 29 | return ( 30 |
31 | 32 |
{props.text || text}
33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/render/pages/statistic/RolesTab/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import styles from './index.less' 4 | import Button from '../../../components/Button' 5 | import RoleCard from '../../../components/RoleCard' 6 | import nativeApi from '../../../utils/nativeApi' 7 | 8 | import type { Role } from '../../../../services/getOwnedRoleList' 9 | 10 | interface RolesProp { 11 | data?: Role[] 12 | uid: string 13 | } 14 | 15 | export default function Roles({ data = [], uid }: RolesProp) { 16 | function openCabinet() { 17 | nativeApi.openWindow(`https://enka.network/u/${uid}`) 18 | } 19 | 20 | const roles = data.slice(0, 8) 21 | 22 | return ( 23 |
24 |
25 | {roles.map((e) => ( 26 | 27 | ))} 28 |
29 |
35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /forge.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | packagerConfig: { 3 | appBundleId: 'moe.viki.genshin-helper', 4 | appCopyright: 'Viki. GPL-3.0 license', 5 | icon: './src/assets/icon.ico' 6 | }, 7 | makers: [ 8 | { 9 | name: '@electron-forge/maker-zip', 10 | platforms: ['win32', 'linux', 'darwin'] 11 | } 12 | ], 13 | plugins: [ 14 | { 15 | name: '@electron-forge/plugin-webpack', 16 | config: { 17 | devContentSecurityPolicy: 18 | "default-src 'self' 'unsafe-inline' data:; connect-src *; script-src 'self' 'unsafe-eval' 'unsafe-inline' data:; img-src * data:", 19 | mainConfig: './webpack/main.config.js', 20 | renderer: { 21 | config: './webpack/renderer.config.js', 22 | entryPoints: [ 23 | { 24 | html: './src/render/index.html', 25 | js: './src/render/index.tsx', 26 | name: 'main_window', 27 | preload: { 28 | js: './src/preload.ts' 29 | } 30 | } 31 | ] 32 | } 33 | } 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | /** 进行延时操作的函数 */ 2 | export function wait(ms: number) { 3 | return new Promise((resolve) => setTimeout(resolve, ms)) 4 | } 5 | 6 | // 对象拼接成字符串,类似 URLSearchParams,可选是否对参数编码 7 | export function qs(obj: Record, encode = false) { 8 | let res = '' 9 | for (const [k, v] of Object.entries(obj)) res += `${k}=${encode ? encodeURIComponent(v) : v}&` 10 | return res.slice(0, res.length - 1) 11 | } 12 | 13 | /** 取指定范围随机数的函数 */ 14 | export function random(min = 0, max = 1) { 15 | return Math.floor(Math.random() * (max - min + 1)) + min 16 | } 17 | 18 | /** 对象深拷贝的函数 */ 19 | export function deepClone(obj: T) { 20 | const getType = (e: any) => Object.prototype.toString.call(e).slice(8, -1) 21 | const isValid = (e: any) => getType(e) === 'Object' || getType(e) === 'Array' 22 | 23 | if (!isValid(obj)) { 24 | return obj 25 | } 26 | 27 | const targetObj = Array.isArray(obj) ? [] : ({} as any) 28 | 29 | for (const key in obj) { 30 | if (Object.prototype.hasOwnProperty.call(obj, key)) { 31 | targetObj[key] = isValid(obj[key]) ? deepClone(obj[key]) : obj[key] 32 | } 33 | } 34 | 35 | return targetObj 36 | } 37 | -------------------------------------------------------------------------------- /src/render/auth/AuthProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import AuthContext from './AuthContext' 4 | import useMount from '../hooks/useMount' 5 | import nativeApi from '../utils/nativeApi' 6 | 7 | const { Provider } = AuthContext 8 | 9 | type AuthProviderProp = { 10 | children: React.ReactNode 11 | isLogin: boolean 12 | } 13 | 14 | export default function AuthProvider(props: AuthProviderProp) { 15 | const { children, isLogin: logged } = props 16 | const [isLogin, setIsLogin] = React.useState(logged) 17 | 18 | const login = () => setIsLogin(true) 19 | 20 | const logout = async (uid?: string, isClear = false) => { 21 | setIsLogin(false) 22 | 23 | if (isClear) { 24 | return 25 | } 26 | 27 | if (uid) { 28 | nativeApi.deleteUser(uid) 29 | } 30 | 31 | nativeApi.setStoreKey('currentUid', '') 32 | } 33 | 34 | useMount(() => { 35 | ;(async () => { 36 | const uid = await nativeApi.getStoreKey('currentUid') 37 | const hasUid = Boolean(uid) 38 | ;(hasUid ? login : logout)() 39 | })() 40 | }) 41 | 42 | return {children} 43 | } 44 | -------------------------------------------------------------------------------- /src/main/handleUsers.ts: -------------------------------------------------------------------------------- 1 | import { session } from 'electron' 2 | 3 | import { store } from '.' 4 | 5 | import type { UserData } from '../typings' 6 | 7 | // 一系列用户操作的函数 8 | 9 | /** 按 UID 删除已登录用户 */ 10 | export async function deleteUser(uid: string) { 11 | // 读取本地已登录用户列表 12 | const users: UserData[] = store.get('users') 13 | // 过滤掉待删除用户 14 | const newUsers = users.filter((e) => e.uid !== uid) 15 | // 保存已过滤的用户列表 16 | store.set('users', newUsers) 17 | } 18 | 19 | /** 清空 Session 中所有有关米哈游的 Cookie 信息 */ 20 | export async function clearSessionCookie() { 21 | // 获取默认 Session 22 | const ses = session.defaultSession 23 | // 获取所有米哈游相关 Cookie 24 | const mihoyoCks = await ses.cookies.get({ domain: 'mihoyo.com' }) 25 | // 遍历清空 26 | mihoyoCks.forEach((ck) => { 27 | // 判断协议 28 | const protocal = ck.secure ? 'https://' : 'http://' 29 | // 拼接域 30 | const link = ck.domain + ck.path 31 | // 按照域和名称移除 Cookie 32 | ses.cookies.remove(protocal + link, ck.name) 33 | }) 34 | } 35 | 36 | /** 切换账号,为防止 Session 冲突,切换时清空 Seession 的缓存 */ 37 | export async function changeUser(uid: string) { 38 | // 配置当前 uid 字段 39 | store.set('currentUid', uid) 40 | // 清空 Seession 有关米哈游的 Cookie 缓存 41 | await clearSessionCookie() 42 | } 43 | -------------------------------------------------------------------------------- /src/main/initStore.ts: -------------------------------------------------------------------------------- 1 | import Store from 'electron-store' 2 | 3 | import type { AppData } from '../typings' 4 | import type { Schema } from 'electron-store' 5 | 6 | export const DefaultAppData: AppData = { 7 | currentUid: '', 8 | users: [], 9 | settings: { 10 | alwaysOnTop: false, 11 | deviceId: '', 12 | gameDir: '' 13 | } 14 | } 15 | 16 | /** 定义 Store 的 JSON schema */ 17 | const schema: Schema = { 18 | currentUid: { type: 'string' }, 19 | users: { 20 | type: 'array', 21 | items: { 22 | type: 'object', 23 | properties: { 24 | uid: { type: 'string', pattern: '^[0-9]{0,10}$' }, 25 | cookie: { type: 'string' } 26 | } 27 | } 28 | }, 29 | settings: { 30 | type: 'object', 31 | properties: { 32 | alwaysOnTop: { 33 | type: 'boolean', 34 | default: false 35 | }, 36 | deviceId: { 37 | type: 'string', 38 | default: '' 39 | }, 40 | gameDir: { 41 | type: 'string', 42 | default: '' 43 | } 44 | } 45 | } 46 | } 47 | 48 | /** 初始化 Store */ 49 | export function initStore() { 50 | const options = { schema, defaults: DefaultAppData } 51 | return new Store(options) 52 | } 53 | -------------------------------------------------------------------------------- /src/render/hooks/useInterval.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react' 2 | 3 | import useLatest from './useLatest' 4 | 5 | /** 6 | * @from https://github.com/alibaba/hooks/blob/master/packages/hooks/src/useInterval/index.ts 7 | */ 8 | function useInterval( 9 | fn: () => void, 10 | delay: number | undefined, 11 | options: { 12 | immediate?: boolean 13 | } = {} 14 | ) { 15 | const { immediate } = options 16 | 17 | const fnRef = useLatest(fn) 18 | const timerRef = useRef(null) 19 | 20 | useEffect(() => { 21 | if (typeof delay !== 'number' || delay < 0) { 22 | return 23 | } 24 | 25 | if (immediate) { 26 | fnRef.current() 27 | } 28 | 29 | timerRef.current = setInterval(() => { 30 | fnRef.current() 31 | }, delay) 32 | 33 | return () => { 34 | if (timerRef.current) { 35 | clearInterval(timerRef.current) 36 | } 37 | } 38 | // eslint-disable-next-line react-hooks/exhaustive-deps 39 | }, [delay]) 40 | 41 | const clear = useCallback(() => { 42 | if (timerRef.current) { 43 | clearInterval(timerRef.current) 44 | } 45 | }, []) 46 | 47 | return clear 48 | } 49 | 50 | export default useInterval 51 | -------------------------------------------------------------------------------- /src/services/doBBSSign.ts: -------------------------------------------------------------------------------- 1 | import { getBBSSignActId } from './getBBSSignActId' 2 | import { API_TAKUMI, LINK_BBS_REFERER } from '../constants' 3 | import { getCurrentUser } from '../main/IPC/getCurrentUser' 4 | import { getSignDS } from '../utils/getDS' 5 | import { getServerByUid } from '../utils/getServerByUid' 6 | import { request } from '../utils/request' 7 | 8 | import type { BaseRes } from '../typings' 9 | 10 | export interface DoSignData { 11 | code: string 12 | } 13 | 14 | export async function doBBSSign() { 15 | const currentUser = getCurrentUser() 16 | 17 | if (!currentUser) { 18 | throw new Error('current user is empty') 19 | } 20 | 21 | const { cookie, uid } = currentUser 22 | const actId = getBBSSignActId() 23 | 24 | const postData = { act_id: actId, region: getServerByUid(uid), uid } 25 | 26 | const headers = { 27 | referer: LINK_BBS_REFERER, 28 | cookie, 29 | DS: getSignDS() 30 | } 31 | 32 | const url = `${API_TAKUMI}/event/bbs_sign_reward/sign` 33 | 34 | const { status, data } = await request.post>(url, postData, { headers }) 35 | 36 | if (status !== 200 || data?.retcode !== 0) { 37 | console.log('doBBSSign: ', data) 38 | } 39 | 40 | return data 41 | } 42 | -------------------------------------------------------------------------------- /src/render/pages/setting/General/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useNavigate } from 'react-router-dom' 3 | 4 | import styles from './index.less' 5 | import Button from '../../../components/Button' 6 | import useAuth from '../../../hooks/useAuth' 7 | import nativeApi from '../../../utils/nativeApi' 8 | 9 | import type { Notice } from '../../../hooks/useNotice' 10 | 11 | interface GeneralProp { 12 | notice: Notice 13 | } 14 | 15 | export default function General({ notice }: GeneralProp) { 16 | const auth = useAuth() 17 | const navigate = useNavigate() 18 | 19 | async function handleClearData() { 20 | const isOK = await nativeApi.clearData() 21 | 22 | if (isOK) { 23 | auth.logout(undefined, true) 24 | } 25 | 26 | notice[isOK ? 'success' : 'failed'](isOK ? '重置成功,建议重启软件' : '无读写权限') 27 | 28 | setTimeout(() => navigate('/'), 1000) 29 | } 30 | 31 | return ( 32 |
33 |
持续开发中,敬请期待
34 |
35 |
40 |
41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/services/getBBSSignInfo.ts: -------------------------------------------------------------------------------- 1 | import { getBBSSignActId } from './getBBSSignActId' 2 | import { API_TAKUMI, LINK_BBS_REFERER } from '../constants' 3 | import { getCurrentUser } from '../main/IPC/getCurrentUser' 4 | import { getServerByUid } from '../utils/getServerByUid' 5 | import { request } from '../utils/request' 6 | 7 | import type { BaseRes } from '../typings' 8 | 9 | export interface SignInfo { 10 | first_bind: boolean 11 | is_sign: boolean 12 | is_sub: boolean 13 | month_first: boolean 14 | sign_cnt_missed: number 15 | today: string 16 | total_sign_day: number 17 | } 18 | 19 | export async function getBBSSignInfo() { 20 | const currentUser = getCurrentUser() 21 | 22 | if (!currentUser) { 23 | return null 24 | } 25 | 26 | const { cookie, uid } = currentUser 27 | const actId = getBBSSignActId() 28 | 29 | const params = { act_id: actId, uid, region: getServerByUid(uid) } 30 | const url = `${API_TAKUMI}/event/bbs_sign_reward/info` 31 | const config = { params, headers: { referer: LINK_BBS_REFERER, cookie } } 32 | 33 | const { status, data } = await request.get>(url, config) 34 | 35 | if (status !== 200 || data?.retcode !== 0) { 36 | console.log('getBBSSignInfo: ', data) 37 | } 38 | 39 | return data 40 | } 41 | -------------------------------------------------------------------------------- /src/render/pages/statistic/index.less: -------------------------------------------------------------------------------- 1 | @import '../../utils/colors.less'; 2 | @import '../../utils/utils.less'; 3 | 4 | .container { 5 | .ani-show-right(); 6 | .flex-center-y-column(); 7 | 8 | flex: 1; 9 | position: relative; 10 | 11 | .top { 12 | .ani-show-top(); 13 | .flex-center(); 14 | 15 | height: 82px; 16 | background-color: @color-bg; 17 | position: relative; 18 | 19 | .btn { 20 | margin-left: 20px; 21 | } 22 | 23 | .user { 24 | position: absolute; 25 | left: 80px; 26 | 27 | & > div:first-child { 28 | font-size: 18px; 29 | } 30 | 31 | & > div:nth-child(2) { 32 | color: @color-second; 33 | font-size: 12px; 34 | } 35 | } 36 | 37 | .input-area { 38 | .ani-show-right(); 39 | .flex-center-y(); 40 | 41 | position: absolute; 42 | right: 32px; 43 | 44 | input { 45 | text-align: center; 46 | width: 120px; 47 | margin-right: 12px; 48 | } 49 | } 50 | } 51 | 52 | .content { 53 | margin: 0 20px 20px 20px; 54 | width: 930px; 55 | height: 450px; 56 | overflow: hidden; 57 | } 58 | 59 | .back-btn { 60 | left: 20px; 61 | position: absolute; 62 | top: 20px; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const { builtinModules } = require('node:module') 2 | 3 | const modules = builtinModules.map((mod) => `node:${mod}`) 4 | 5 | const importOrder = [ 6 | ['builtin', 'external'], 7 | 'internal', 8 | ['sibling', 'parent', 'object', 'index'], 9 | 'type', 10 | 'unknown' 11 | ] 12 | 13 | module.exports = { 14 | extends: ['plugin:import/electron', 'viki-react', 'plugin:prettier/recommended'], 15 | rules: { 16 | '@typescript-eslint/triple-slash-reference': 'off', 17 | 18 | 'import/no-nodejs-modules': ['error', { allow: [...modules, 'electron'] }], 19 | 'import/no-mutable-exports': 'off', 20 | 21 | 'jsx-a11y/click-events-have-key-events': 'off', 22 | 'jsx-a11y/no-static-element-interactions': 'off', 23 | 'jsx-a11y/no-noninteractive-element-interactions': 'off', 24 | 'jsx-a11y/alt-text': 'off', 25 | 26 | 'no-console': 'off', 27 | 'no-restricted-syntax': 'off', 28 | 'no-nested-ternary': 'off', 29 | 'no-await-in-loop': 'off', 30 | 31 | 'import/order': [ 32 | 1, 33 | { 34 | 'newlines-between': 'always', 35 | groups: importOrder, 36 | warnOnUnassignedImports: true, 37 | alphabetize: { 38 | order: 'asc', 39 | caseInsensitive: true 40 | } 41 | } 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/render/pages/gacha/utils/filterGachaList.ts: -------------------------------------------------------------------------------- 1 | import type { FilterType } from '..' 2 | import type { GachaData, GachaItemType, GachaType } from '../../../../typings' 3 | 4 | export const GachaTypeMap: Record = { 5 | weapon: '302', 6 | activity: '301', 7 | normal: '200', 8 | newer: '100' 9 | } 10 | 11 | export const ItemTypeMap: Record = { 12 | weapon: '武器', 13 | role: '角色' 14 | } 15 | 16 | function transformGachaType(type: FilterType['gacha']): string[] { 17 | return type.map((e) => GachaTypeMap[e]) 18 | } 19 | 20 | function transformItemType(type: FilterType['item']): string[] { 21 | return type.map((e) => ItemTypeMap[e]) 22 | } 23 | 24 | function transformStarType(type: FilterType['star']): string[] { 25 | return type.map((e) => String(e)) 26 | } 27 | 28 | export default function filterGachaList(list: GachaData['list'], type: FilterType) { 29 | const { item: itemType, gacha: gachaType, star: starType } = type 30 | 31 | let result = [...list] 32 | 33 | result = result.filter((e) => transformItemType(itemType).includes(e.item_type)) 34 | result = result.filter((e) => transformGachaType(gachaType).includes(e.uigf_gacha_type)) 35 | result = result.filter((e) => transformStarType(starType).includes(e.rank_type)) 36 | 37 | return result 38 | } 39 | -------------------------------------------------------------------------------- /src/render/router.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { HashRouter, Route, Routes } from 'react-router-dom' 3 | 4 | import Calendar from './pages/calendar' 5 | import Gacha from './pages/gacha' 6 | import Home from './pages/home' 7 | import Login from './pages/login' 8 | import Note from './pages/note' 9 | import Portal from './pages/portal' 10 | import Role from './pages/role' 11 | import Setting from './pages/setting' 12 | import Sign from './pages/sign' 13 | import Statistic from './pages/statistic' 14 | import Strategy from './pages/strategy' 15 | 16 | const AppRouter: React.FC = () => ( 17 | 18 | 19 | } /> 20 | } /> 21 | } /> 22 | } /> 23 | } /> 24 | } /> 25 | } /> 26 | } /> 27 | } /> 28 | } /> 29 | } /> 30 | 31 | 32 | ) 33 | 34 | export default AppRouter 35 | -------------------------------------------------------------------------------- /src/main/IPC/openWindow.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow } from 'electron' 2 | 3 | import { isDev, store } from '..' 4 | import { APP_USER_AGENT_DESKTOP } from '../../constants' 5 | 6 | import type { BrowserWindowConstructorOptions } from 'electron' 7 | 8 | export const subWins: Set = new Set() 9 | 10 | export async function openWindow( 11 | _: Electron.IpcMainEvent, 12 | url: string, 13 | options: BrowserWindowConstructorOptions = {}, 14 | UA = '' 15 | ) { 16 | const win = new BrowserWindow({ 17 | width: 1300, 18 | height: 803, 19 | autoHideMenuBar: true, 20 | backgroundColor: '#F9F6F2', 21 | alwaysOnTop: isDev || store.get('settings').alwaysOnTop, 22 | ...options 23 | }) 24 | 25 | if (!isDev) { 26 | win.removeMenu() 27 | } 28 | 29 | subWins.add(win) 30 | 31 | win.addListener('close', () => subWins.delete(win)) 32 | 33 | const dom = win.webContents 34 | 35 | // 在窗口内跳转 36 | dom.setWindowOpenHandler((details) => { 37 | dom.loadURL(details.url) 38 | return { action: 'deny' } 39 | }) 40 | 41 | // 设置 UA 42 | dom.setUserAgent(UA || APP_USER_AGENT_DESKTOP + app.getVersion()) 43 | 44 | // 加载页面 45 | dom.loadURL(url) 46 | 47 | // 鼠标右键返回上一页面 48 | dom.addListener('context-menu', () => { 49 | if (dom.canGoBack()) { 50 | dom.goBack() 51 | } 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /src/utils/getGameDir.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron' 2 | import fs from 'fs-extra' 3 | import path from 'node:path' 4 | 5 | import { GAME_NAME } from '../constants' 6 | import { store } from '../main' 7 | 8 | /** 获取原神游戏在本地启动日志里的安装目录 */ 9 | export function getGameDir() { 10 | const dir = store.get('settings.gameDir') 11 | 12 | if (dir) { 13 | if (fs.existsSync(dir)) { 14 | return path.join(dir, 'Genshin Impact Game') 15 | } 16 | 17 | store.set('settings.gameDir', '') 18 | } 19 | 20 | // 日志文件子路径 21 | const subPath = 'AppData/LocalLow/miHoYo' 22 | // 匹配系统语言对应的游戏名称 23 | const gameName = GAME_NAME[app.getLocale().includes('zh') ? 'zh' : 'en'] 24 | // 日志文件名 25 | const filename = 'output_log.txt' 26 | 27 | // 拼接最终日志文件路径 28 | const logPath = path.join(app.getPath('home'), subPath, gameName, filename) 29 | 30 | // 尝试读取日志文件内容 31 | const logContent = fs.readFileSync(logPath, { encoding: 'utf8' }) 32 | // 在日志文件里使用正则表达式搜索游戏安装目录 33 | const gameDirReg = /(\w:\/.+Genshin Impact)\// 34 | // 获取游戏安装目录 35 | const gameDir = gameDirReg.test(logContent) ? gameDirReg.exec(logContent)[1] : '' 36 | 37 | console.log('getGameDir: ', gameDir) 38 | 39 | if (gameDir && fs.existsSync(gameDir)) { 40 | store.set('settings.gameDir', gameDir) 41 | return path.join(gameDir, 'Genshin Impact Game') 42 | } 43 | 44 | return '' 45 | } 46 | -------------------------------------------------------------------------------- /src/render/pages/role/API.md: -------------------------------------------------------------------------------- 1 | list API :https://ys.mihoyo.com/content/ysCn/getContentList?pageSize=1000&pageNum=1&channelId=152 2 | content API :https://ys.mihoyo.com/content/ysCn/getContent?contentId=16496 3 | 4 | const res = { 5 | SLIDER: { 6 | HOME_BIG: 6, 7 | HOME_SMALL: 7, 8 | EVENT: 257 9 | }, 10 | NEWS: { 11 | TOP_GRID: 259, // 最新资讯 12 | LATEST: 10, 13 | NOTICE: 12 14 | }, 15 | MANGA: { 16 | CHAPTERS: 15 17 | }, 18 | MAP: { // https://ys.mihoyo.com/content/ysCn/getContent?contentId=16496 19 | LIST: 206 20 | }, 21 | APPOINTMENT: { 22 | QUESTIONNARIE: 123 23 | }, 24 | CONFIG: { 25 | SOCIAL: 488, 26 | CITY: 212 // 获取所有区域 27 | }, 28 | COMPANY: { 29 | AGREEMENT: 214, 30 | PRIVACY: 215 31 | }, 32 | CHARACTER: { 33 | DETAIL: 152, // 所有 34 | MONDSTADT: 150, // 蒙德 35 | LIYUE: 151, // 璃月 36 | INAZUMA: 324 // 稻妻 37 | }, 38 | NOTE: { 39 | MONDSTADT: 163, 40 | LIYUE: 164, 41 | INAZUMA: 325 42 | } 43 | }; 44 | 45 | 当前开放的区域 data.list[i].contentId 46 | 47 | https://ys.mihoyo.com/content/ysCn/getContentList?pageSize=1000&pageNum=1&channelId=206 48 | https://ys.mihoyo.com/content/ysCn/getContentList?pageSize=1000&pageNum=1&order=asc&channelId=152 49 | https://ys.mihoyo.com/content/ysCn/getContent?contentId=16496 50 | https://upload-bbs.mihoyo.com/game_record/genshin/character_icon/UI_AvatarIcon_Lisa.png 51 | https://upload-bbs.mihoyo.com/game_record/genshin/character_side_icon/UI_AvatarIcon_Side_Ambor.png 52 | -------------------------------------------------------------------------------- /src/render/utils/colors.less: -------------------------------------------------------------------------------- 1 | @color-accent: #ffa564; 2 | @color-accent-deep: #aa886d; 3 | @color-accent-light: #ffdbc1; 4 | @color-bg: #f9f6f2; 5 | @color-bg-deep: #ebe7df; 6 | @color-bg-second: #dfdcd4; 7 | @color-bg-accent: #ece5d8; 8 | @color-bg-primary: #454d5c; 9 | @color-primary: #495366; 10 | @color-second: #ababae; 11 | @color-white: #fdfdfb; 12 | 13 | @color-failed: #d17877; 14 | @color-info: #4d8ccb; 15 | @color-success: #8ab648; 16 | @color-warning: #d09c65; 17 | 18 | @color-star-3: #73abcd; 19 | @color-star-4: #9779c2; 20 | @color-star-5: #ffa564; 21 | // @color-star-5 : #da9559; 22 | @color-star-6: #a0301b; 23 | 24 | @color-luck-0: #4d8ccb; 25 | @color-luck-0-s: #759abf; 26 | @color-luck-1: #e4b44d; 27 | @color-luck-1-s: #e4b95b; 28 | @color-luck-2: #9d78d2; 29 | @color-luck-2-s: #aa96c7; 30 | @color-luck-3: #9ed052; 31 | @color-luck-3-s: #a0bb77; 32 | @color-luck-4: #93979e; 33 | @color-luck-4-s: #b7b7b7; 34 | @color-luck-5: #505a6d; 35 | @color-luck-5-s: #6c6c6c; 36 | 37 | @color-pyro: #ffa870; 38 | @color-pyro-deep: #da6217; 39 | @color-hydro: #08e0fa; 40 | @color-hydro-deep: #059bcd; 41 | @color-anemo: #8df4bf; 42 | @color-anemo-deep: #299b74; 43 | @color-electro: #ddb9fe; 44 | @color-electro-deep: #a663d0; 45 | @color-geo: #f4d660; 46 | @color-geo-deep: #d08d05; 47 | @color-cryo: #78d7dd; 48 | @color-cryo-deep: #46c3cc; 49 | @color-dendro: #9ddb0c; 50 | @color-dendro-deep: #79a01a; 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | .DS_Store 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # TypeScript cache 43 | *.tsbuildinfo 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | .env.test 63 | 64 | # parcel-bundler cache (https://parceljs.org/) 65 | .cache 66 | 67 | # next.js build output 68 | .next 69 | 70 | # node modules 71 | node_modules/ 72 | 73 | # webpack 74 | .webpack/ 75 | 76 | # Electron-Forge 77 | out/ 78 | -------------------------------------------------------------------------------- /src/render/hooks/useApi.ts: -------------------------------------------------------------------------------- 1 | // part of inspiration comes from https://github.com/alibaba/hooks/blob/master/packages/hooks/src/useRequest/src/useRequest.ts 2 | 3 | import { useState } from 'react' 4 | 5 | export type Service = (...args: TParams) => Promise 6 | 7 | function useApi( 8 | service: Service, 9 | options = { clear: true } 10 | ) { 11 | const [loading, setLoading] = useState(false) 12 | const [data, setData] = useState() 13 | const [error, setError] = useState('') 14 | 15 | async function run(...args: TParams) { 16 | setLoading(true) 17 | setError('') 18 | 19 | try { 20 | const res = await service(...args) 21 | 22 | console.log('useApi: ', res) 23 | 24 | if (res) { 25 | setData(res) 26 | } else { 27 | if (options.clear) { 28 | setData(undefined) 29 | } 30 | } 31 | 32 | setLoading(false) 33 | 34 | return res 35 | } catch (e) { 36 | const isOffline = e?.message?.includes('getaddrinfo') 37 | const msg = isOffline ? '网络状况不佳,请检查后重试 T_T' : '加载超时,请检查网络连接 T_T' 38 | 39 | setError(msg) 40 | setLoading(false) 41 | 42 | return false 43 | } 44 | } 45 | 46 | return { run, r: run, data, d: data, error, e: error, loading, l: loading } as const 47 | } 48 | 49 | export default useApi 50 | -------------------------------------------------------------------------------- /src/render/components/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import React from 'react' 3 | import { BiCircle } from 'react-icons/bi' 4 | import { BsXLg } from 'react-icons/bs' 5 | 6 | import styles from './index.less' 7 | 8 | import type { IconType } from 'react-icons' 9 | 10 | export interface ButtonProp { 11 | type?: 'confirm' | 'cancel' 12 | size?: 'small' | 'middle' | 'large' 13 | theme?: 'light' | 'dark' 14 | style?: React.CSSProperties 15 | text: string 16 | className?: string 17 | onClick?: (...args: any[]) => any 18 | } 19 | 20 | const SIZE_MAP = { 21 | small: 12, 22 | middle: 20, 23 | large: 30 24 | } 25 | 26 | const TYPE_MAP: Record = { 27 | confirm: BiCircle, 28 | cancel: BsXLg 29 | } 30 | 31 | export default function Button(props: ButtonProp) { 32 | const { onClick, className = '', size = 'small', theme = 'dark', style = {}, type, text } = props 33 | 34 | const Icon = type ? TYPE_MAP[type] : null 35 | 36 | const divClass = cn(styles.btn, styles[theme], styles[size], className) 37 | const iconClass = Icon ? cn(styles.icon, type ? styles[type] : '') : '' 38 | const textClass = cn(styles.text, Icon ? styles.withIcon : styles.noIcon) 39 | 40 | return ( 41 |
42 | {Icon && } 43 |
{text}
44 |
45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /src/services/getBBSSignActId.ts: -------------------------------------------------------------------------------- 1 | // import { API_BBS, LINK_BBS_REFERER } from '../constants'; 2 | // import request from '../utils/request'; 3 | 4 | // import type { BaseRes } from '../typings'; 5 | 6 | // interface Navigator { 7 | // app_path: string; 8 | // icon: string; 9 | // id: number; 10 | // name: string; 11 | // reddot_online_time: string; 12 | // } 13 | 14 | // interface BBSHomeData { 15 | // background: any; 16 | // carousels: any; 17 | // discussion: any; 18 | // game_receptions: any[]; 19 | // hot_topics: any; 20 | // navigator: Navigator[]; 21 | // official: any; 22 | // posts: any[]; 23 | // } 24 | 25 | // const getBBSSignActId = async (): Promise => { 26 | // const headers = { referer: LINK_BBS_REFERER }; 27 | // const url = `${API_BBS}/apihub/api/home/new?gids=2`; 28 | 29 | // const { status, data } = await request.get>(url, { 30 | // headers, 31 | // }); 32 | 33 | // const isOK = status === 200 && data.retcode === 0; 34 | 35 | // if (!isOK) { 36 | // console.log('getBBSSignActId: ', data); 37 | // } 38 | 39 | // const signPageUrl = data.data.navigator.filter((e: any) => e.name.includes('签到'))[0] 40 | // .app_path || ''; 41 | 42 | // const params = new URLSearchParams(signPageUrl.split('?').reverse()[0]); 43 | // return params.get('act_id') || 'e202009291139501'; 44 | // }; 45 | 46 | export function getBBSSignActId() { 47 | return 'e202009291139501' 48 | } 49 | -------------------------------------------------------------------------------- /src/main/IPC/getGachaUrl.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron' 2 | import fs from 'fs-extra' 3 | import path from 'node:path' 4 | 5 | import { isDev, isAppleDevice } from '..' 6 | import { getGameDir } from '../../utils/getGameDir' 7 | 8 | /** 获取原神游戏在本地缓存里的祈愿记录链接,只有在游戏里打开过祈愿记录页面,缓存里才会有祈愿链接 */ 9 | export async function getGachaUrl() { 10 | if (isAppleDevice) { 11 | return '' 12 | } 13 | 14 | try { 15 | // 获取游戏安装目录 16 | const gameDir = await getGameDir() 17 | 18 | if (!gameDir) { 19 | return '' 20 | } 21 | 22 | // 系统语言 23 | const lang = app.getLocale() 24 | // 游戏本体数据目录名 25 | const name = lang.includes('zh') ? 'YuanShen_Data' : 'GenshinImpact_Data' 26 | // 游戏本体 web 缓存目录 27 | const subDir = `${name}/webCaches/Cache/Cache_Data/data_2` 28 | // web 缓存文件路径 29 | const cacheFilePath = path.join(gameDir, subDir) 30 | 31 | if (!fs.existsSync(cacheFilePath)) { 32 | return '' 33 | } 34 | 35 | // 读取 web 缓存文件 36 | const content = fs.readFileSync(cacheFilePath, { encoding: 'utf8' }) 37 | // 祈愿链接正则 38 | const UrlReg = /https.+?game_biz=hk4e_\w+/g 39 | // 正则匹配祈愿链接 40 | const urlMatches = content.match(UrlReg) || [''] 41 | // 读取祈愿链接 42 | const url = urlMatches[urlMatches.length - 1] 43 | 44 | if (isDev) { 45 | console.log('getGachaUrl: ', `${url.split('?')[0]}?...`) 46 | } 47 | 48 | return url 49 | } catch (e) { 50 | console.log(e) 51 | return '' 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/render/components/SelectButton/index.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import React from 'react' 3 | 4 | import styles from './index.less' 5 | 6 | export type SelectItem = { 7 | label: string 8 | value: string | number 9 | } 10 | 11 | export interface SelectButtonProp { 12 | changeItem?: (...args: any[]) => any 13 | className?: string 14 | direction?: 'vertical' | 'horizontal' 15 | height?: number 16 | items: SelectItem[] 17 | style?: React.CSSProperties 18 | selectedStyle?: React.CSSProperties 19 | value?: string | number 20 | width?: number 21 | } 22 | 23 | export default function SelectButton(props: SelectButtonProp) { 24 | const { 25 | changeItem, 26 | className, 27 | direction = 'horizontal', 28 | height, 29 | items, 30 | style, 31 | selectedStyle, 32 | value, 33 | width 34 | } = props 35 | 36 | const isHori = direction === 'horizontal' 37 | const divClass = cn(styles.wrapper, className, isHori ? '' : styles.vertical) 38 | const divStyle = { width, height, ...style } 39 | 40 | return ( 41 |
42 | {items.map((e) => ( 43 |
49 | {e.label} 50 |
51 | ))} 52 |
53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/render/components/CircleButton/index.less: -------------------------------------------------------------------------------- 1 | @import '../../utils/colors.less'; 2 | @import '../../utils/utils.less'; 3 | 4 | @size-small: 24px; 5 | @size-middle: 36px; 6 | @size-large: 54px; 7 | 8 | @win-bar-bg-color: #454d5c; 9 | 10 | @cbtn-active-bg-color: #f5f4ee; 11 | @cbtn-active-round-line: #e5e5e5; 12 | @cbtn-bg-color: #ffffff; 13 | @cbtn-hover-bg-color: #f9f6f2; 14 | @cbtn-hover-round-line: #9f9f9f; 15 | @cbtn-round-line: #454d5c; 16 | 17 | .btn { 18 | .transition(); 19 | .flex-center(); 20 | 21 | background-color: @cbtn-bg-color; 22 | overflow: hidden; 23 | 24 | .icon { 25 | .transition(); 26 | 27 | color: @win-bar-bg-color; 28 | } 29 | 30 | &:hover { 31 | background-color: @cbtn-hover-bg-color; 32 | border-color: @cbtn-hover-round-line; 33 | } 34 | 35 | &:active { 36 | background-color: @cbtn-active-bg-color; 37 | border-color: @cbtn-active-round-line; 38 | 39 | .icon { 40 | color: @color-second; 41 | } 42 | } 43 | } 44 | 45 | .small { 46 | border: 3px solid @cbtn-round-line; 47 | border-radius: calc((@size-small + 6px) / 2); 48 | height: @size-small; 49 | width: @size-small; 50 | } 51 | 52 | .middle { 53 | border: 3px solid @cbtn-round-line; 54 | border-radius: calc((@size-middle + 8px) / 2); 55 | height: @size-middle; 56 | width: @size-middle; 57 | } 58 | 59 | .large { 60 | border: 3px solid @cbtn-round-line; 61 | border-radius: calc((@size-large + 10px) / 2); 62 | height: @size-large; 63 | width: @size-large; 64 | } 65 | -------------------------------------------------------------------------------- /src/render/components/ItemCard/index.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import React from 'react' 3 | 4 | import styles from './index.less' 5 | import star1 from '../../../assets/star1.png' 6 | import star2 from '../../../assets/star2.png' 7 | import star3 from '../../../assets/star3.png' 8 | import star4 from '../../../assets/star4.png' 9 | import star5 from '../../../assets/star5.png' 10 | 11 | import type { MouseEventHandler } from 'react' 12 | 13 | type CardItemInfo = { 14 | rarity: number 15 | icon: string 16 | level: number 17 | name: string 18 | } 19 | 20 | export interface ItemCardProp { 21 | className?: string 22 | withName?: boolean 23 | onClick?: MouseEventHandler 24 | item: CardItemInfo 25 | style?: React.CSSProperties 26 | } 27 | 28 | const StarImgs: string[] = [star1, star2, star3, star4, star5] 29 | 30 | export default function ItemCard({ 31 | className, 32 | onClick, 33 | item, 34 | style, 35 | withName = true 36 | }: ItemCardProp) { 37 | const getStarClass = (rarity: number) => styles[`star${rarity > 5 ? 6 : rarity}`] 38 | const getStarImage = (rarity: number) => StarImgs[(rarity > 5 ? 5 : rarity) - 1] 39 | 40 | return ( 41 |
42 |
43 | 44 | 45 | {withName && {item.name}} 46 | {item?.level > 0 &&
{item.level}
} 47 |
48 |
49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /src/render/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | declare namespace NodeJS { 6 | interface ProcessEnv { 7 | readonly NODE_ENV: 'development' | 'production' | 'test' 8 | readonly PUBLIC_URL: string 9 | } 10 | } 11 | 12 | declare module '*.avif' { 13 | const src: string 14 | export default src 15 | } 16 | 17 | declare module '*.bmp' { 18 | const src: string 19 | export default src 20 | } 21 | 22 | declare module '*.gif' { 23 | const src: string 24 | export default src 25 | } 26 | 27 | declare module '*.jpg' { 28 | const src: string 29 | export default src 30 | } 31 | 32 | declare module '*.jpeg' { 33 | const src: string 34 | export default src 35 | } 36 | 37 | declare module '*.png' { 38 | const src: string 39 | export default src 40 | } 41 | 42 | declare module '*.webp' { 43 | const src: string 44 | export default src 45 | } 46 | 47 | declare module '*.ico' { 48 | const src: string 49 | export default src 50 | } 51 | 52 | declare module '*.svg' { 53 | import type * as React from 'react' 54 | 55 | export const ReactComponent: React.FunctionComponent< 56 | React.SVGProps & { title?: string } 57 | > 58 | 59 | const src: string 60 | export default src 61 | } 62 | 63 | declare module '*.css' { 64 | const classes: { readonly [key: string]: string } 65 | export default classes 66 | } 67 | 68 | declare module '*.less' { 69 | const classes: { readonly [key: string]: string } 70 | export default classes 71 | } 72 | -------------------------------------------------------------------------------- /src/render/pages/setting/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { TiArrowBack } from 'react-icons/ti' 3 | import { useLocation, useNavigate } from 'react-router-dom' 4 | 5 | import About from './About' 6 | import General from './General' 7 | import styles from './index.less' 8 | import CircleButton from '../../components/CircleButton' 9 | import SelectButton from '../../components/SelectButton' 10 | import useNotice from '../../hooks/useNotice' 11 | 12 | interface LocationState { 13 | tab?: 'general' | 'about' 14 | } 15 | 16 | export default function Setting() { 17 | const notice = useNotice() 18 | const navigate = useNavigate() 19 | const state = useLocation().state as LocationState 20 | const [tab, setTab] = useState(state?.tab ?? 'general') 21 | 22 | const tabs = [ 23 | { label: '通用设置', value: 'general' }, 24 | { label: '关于原神助手', value: 'about' } 25 | ] 26 | 27 | return ( 28 | <> 29 |
30 |
31 | 32 |
33 |
34 | {tab === 'general' && } 35 | {tab === 'about' && } 36 |
37 | navigate('/')} 42 | /> 43 |
44 | {notice.holder} 45 | 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /src/render/components/WinFrame/index.less: -------------------------------------------------------------------------------- 1 | @import '../../utils/colors.less'; 2 | @import '../../utils/utils.less'; 3 | 4 | @win-bar-bg-color: #454d5c; 5 | @win-bar-bg-line-color: #515a6a; 6 | @win-bar-title-color: #ece5d8; 7 | 8 | .frame { 9 | background-color: @color-bg; 10 | display: flex; 11 | flex-direction: column; 12 | height: 100vh; 13 | overflow: hidden; 14 | width: 100vw; 15 | 16 | .top-bar { 17 | background-color: @win-bar-bg-color; 18 | // box-shadow : 0 0 10px 4px #ccc; 19 | display: flex; 20 | height: 42px; 21 | padding: 2px; 22 | width: 100vw; 23 | z-index: 999999999; 24 | 25 | &::before { 26 | -webkit-app-region: drag; 27 | background-color: transparent; 28 | border: 1px solid @win-bar-bg-line-color; 29 | content: ''; 30 | height: 40px; 31 | position: fixed; 32 | width: calc(100vw - 6px); 33 | } 34 | 35 | .icon { 36 | height: 24px; 37 | margin: 9px; 38 | width: 24px; 39 | } 40 | 41 | .title { 42 | color: @win-bar-title-color; 43 | font-size: 20px; 44 | margin: 10px 10px 10px 0; 45 | } 46 | 47 | .btns { 48 | .flex-center(); 49 | 50 | -webkit-app-region: no-drag; 51 | padding: 8px; 52 | position: absolute; 53 | right: 8px; 54 | top: 0px; 55 | 56 | .btn { 57 | margin-left: 8px; 58 | } 59 | } 60 | } 61 | 62 | .content { 63 | .flex-column(); 64 | 65 | height: calc(100vh - 42px); 66 | background-color: transparent; 67 | overflow: hidden; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/render/components/Select/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import styles from './index.less' 4 | 5 | export interface Option { 6 | value: string | number 7 | label: string 8 | } 9 | 10 | interface SelectProp { 11 | defaultValue?: React.HTMLAttributes['defaultValue'] 12 | key?: React.Attributes['key'] 13 | label?: string 14 | name?: string 15 | onChange?: React.ChangeEventHandler 16 | options?: Option[] 17 | optionStyle?: React.CSSProperties 18 | selectStyle?: React.CSSProperties 19 | title?: string 20 | value?: React.SelectHTMLAttributes['value'] 21 | wrapperStyle?: React.CSSProperties 22 | } 23 | 24 | export default function Select(props: SelectProp) { 25 | const { 26 | defaultValue, 27 | key, 28 | label, 29 | name, 30 | onChange, 31 | options, 32 | optionStyle, 33 | selectStyle, 34 | title, 35 | value, 36 | wrapperStyle 37 | } = props 38 | 39 | return ( 40 |
41 | {label && } 42 | 56 |
57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /src/render/pages/gacha/utils/getMostInfo.ts: -------------------------------------------------------------------------------- 1 | import getListByType from './getListByType' 2 | 3 | import type { GachaData, GachaType } from '../../../../typings' 4 | 5 | export default function getMostInfo(gacha: GachaData) { 6 | const predestined = { name: '', count: 0, valid: false } 7 | const luckest = { name: '', count: 999, valid: false } 8 | const unluckest = { name: '', count: 0, valid: false } 9 | const i5 = [] as { count: number; name: string }[] 10 | 11 | for (const type of ['activity', 'weapon', 'normal'] as GachaType[]) { 12 | const list = getListByType(gacha.list, type) 13 | let lastIndex = -1 14 | for (const [i, e] of list.entries()) { 15 | if (e.rank_type === '5') { 16 | i5.push({ count: i - lastIndex, name: e.name }) 17 | lastIndex = i 18 | } 19 | } 20 | } 21 | 22 | const memo: Record = {} 23 | 24 | for (const e of i5) { 25 | if (e.count < luckest.count) { 26 | luckest.name = e.name 27 | luckest.count = e.count 28 | luckest.valid = true 29 | } 30 | 31 | if (e.count > unluckest.count) { 32 | unluckest.name = e.name 33 | unluckest.count = e.count 34 | unluckest.valid = true 35 | } 36 | 37 | if (memo[e.name]) { 38 | memo[e.name]++ 39 | } else { 40 | memo[e.name] = 1 41 | } 42 | } 43 | 44 | for (const [k, v] of Object.entries(memo)) { 45 | if (Number(v) > predestined.count) { 46 | predestined.name = k 47 | predestined.count = v 48 | predestined.valid = true 49 | } 50 | } 51 | 52 | return { predestined, luckest, unluckest } 53 | } 54 | -------------------------------------------------------------------------------- /src/utils/getGreetingMsg.ts: -------------------------------------------------------------------------------- 1 | // 凌晨、早上、上午、中午、下午、傍晚(黄昏)、晚上、午夜 2 | const GreetingMsgMap: Record = { 3 | wee: '凌晨了,早点休息喵!', 4 | morning: '早上好,今天也是元气慢慢的一天呢!', 5 | forenoon: '上午好,打起精神来!', 6 | noon: '到饭点了,吃了吗?没吃吃我一记喵喵拳()', 7 | afternoon: '下午好,午睡完也要充满干劲喵!', 8 | dusk: '五点了,忙完记得吃饭喵~', 9 | night: '晚上好,今天又到哪去冒险了呢?', 10 | midnight: '夜深了,晚安喵,又是充实的一天呢!', 11 | unknown: '很高兴见到你。' 12 | } 13 | 14 | // 凌晨、早上、上午、中午、下午、傍晚(黄昏)、晚上、午夜 15 | const GreetingShortMsgMap: Record = { 16 | wee: '凌晨了', 17 | morning: '早上好', 18 | forenoon: '上午好', 19 | noon: '中午好', 20 | afternoon: '下午好', 21 | dusk: '傍晚了', 22 | night: '晚上好', 23 | midnight: '夜深了', 24 | unknown: '你好' 25 | } 26 | 27 | /** 按小时获取时间段 */ 28 | const getPeriodByHour = (hour: number) => { 29 | if (hour >= 1 && hour < 5) { 30 | return 'wee' 31 | } 32 | if (hour >= 5 && hour < 8) { 33 | return 'morning' 34 | } 35 | if (hour >= 8 && hour < 11) { 36 | return 'forenoon' 37 | } 38 | if (hour >= 11 && hour < 13) { 39 | return 'noon' 40 | } 41 | if (hour >= 13 && hour < 17) { 42 | return 'afternoon' 43 | } 44 | if (hour >= 17 && hour < 18) { 45 | return 'dusk' 46 | } 47 | if (hour >= 18 && hour < 23) { 48 | return 'night' 49 | } 50 | if (hour === 23 || hour === 0) { 51 | return 'midnight' 52 | } 53 | return 'unknown' 54 | } 55 | 56 | /** 按时间获取当前时间段的打招呼消息 */ 57 | export function getGreetingMsg(date: Date = new Date(), short = false) { 58 | const hour = date.getHours() 59 | const period = getPeriodByHour(hour) 60 | 61 | return (short ? GreetingMsgMap : GreetingShortMsgMap)[period] 62 | } 63 | -------------------------------------------------------------------------------- /src/services/getCalendarList.ts: -------------------------------------------------------------------------------- 1 | import { API_STATIC } from '../constants' 2 | import { request } from '../utils/request' 3 | 4 | import type { BaseRes } from '../typings' 5 | 6 | export interface ContentSource { 7 | /** 秘境名称 */ 8 | title: string 9 | /** 秘境 icon 链接 */ 10 | icon: string 11 | 12 | bbs_url: string 13 | content_id: number 14 | } 15 | 16 | export interface ContentInfo { 17 | /** 材料名称 */ 18 | title: string 19 | /** 材料 icon 链接 */ 20 | icon: string 21 | 22 | bbs_url: string 23 | content_id: number 24 | } 25 | 26 | export interface CalendarEvent { 27 | /** 生日与限时活动0,武器突破材料1,角色天赋突破材料2 */ 28 | break_type: string 29 | /** 限时活动1,突破材料(武器和角色天赋)2,生日4 */ 30 | kind: string 31 | /** 图片链接,仅材料有效 */ 32 | img_url: string 33 | /** 开始时间 */ 34 | start_time: string 35 | /** 结束时间 */ 36 | end_time: string 37 | /** 具体材料信息 */ 38 | contentInfos: ContentInfo[] 39 | /** 材料所在秘境 */ 40 | contentSource: ContentSource[] 41 | /** 掉落星期数组 */ 42 | drop_day: string[] 43 | 44 | content_id: string 45 | font_color: string 46 | id: string 47 | jump_type: string 48 | jump_url: string 49 | padding_color: string 50 | sort: string 51 | style: string 52 | title: string 53 | } 54 | 55 | export interface CalendarData { 56 | list: CalendarEvent[] 57 | } 58 | 59 | export async function getCalendarList() { 60 | const url = `${API_STATIC}/common/blackboard/ys_obc/v1/get_activity_calendar` 61 | 62 | const { status, data } = await request.get>(url, { 63 | params: { app_sn: 'ys_obc' } 64 | }) 65 | 66 | if (status !== 200 || data?.retcode !== 0) { 67 | console.log('getCalendarList: ', data) 68 | } 69 | 70 | return data 71 | } 72 | -------------------------------------------------------------------------------- /src/utils/request.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { v4 as uuid } from 'uuid' 3 | 4 | import { APP_USER_AGENT_BBS, BBS_VERSION } from '../constants' 5 | import { store } from '../main' 6 | 7 | // 创建 Axios 实例并设置默认配置、请求头等 8 | export const request = axios.create({ 9 | timeout: 12000, 10 | headers: { 11 | 'user-agent': APP_USER_AGENT_BBS, 12 | 'content-type': 'application/json; charset=utf-8', 13 | 'x-rpc-app_version': BBS_VERSION, 14 | 'x-rpc-page': 'v3.7.1-ys_#ys', 15 | 'x-rpc-tool_version': 'v3.7.1-ys', 16 | 'x-rpc-client_type': '5', 17 | 'x-rpc-device_name': 'iPhone' 18 | } 19 | }) 20 | 21 | // x-rpc-client_type 字段的说明: 22 | // 23 | // 1: iOS Client 24 | // 2: Android Client // v2.34.1 salt: z8DRIUjNDT7IT5IZXvrUAxyupA1peND9 25 | // 4: PC Web 26 | // 5: Mobile Web // v2.34.1 salt: 9nQiU3AV0rJSIBWgdynfoGMGKaklfbM7 27 | 28 | request.interceptors.request.use( 29 | (config) => { 30 | const id = store.get('settings.deviceId', '') 31 | 32 | if (!id) { 33 | const did = uuid().replace('-', '').toUpperCase() 34 | store.set('settings.deviceId', did) 35 | Object.assign(config.headers, { 'x-rpc-device_id': did }) 36 | } 37 | 38 | return config 39 | }, 40 | (error) => { 41 | console.log(error?.message || error) 42 | return Promise.reject(error) 43 | } 44 | ) 45 | 46 | request.interceptors.response.use( 47 | (response) => { 48 | const { url, method } = response.config 49 | const { hostname } = new URL(url) 50 | 51 | console.log(`${method.toUpperCase()}: ${response.status} => ${hostname}`) 52 | 53 | return response 54 | }, 55 | (error) => { 56 | console.log(error?.message || error) 57 | return Promise.reject(error) 58 | } 59 | ) 60 | -------------------------------------------------------------------------------- /src/render/pages/strategy/index.less: -------------------------------------------------------------------------------- 1 | @import '../../utils/colors.less'; 2 | @import '../../utils/utils.less'; 3 | 4 | .container { 5 | .ani-show-right(); 6 | .flex-center-x-column(); 7 | 8 | flex: 1; 9 | position: relative; 10 | padding: 0 20px; 11 | 12 | .title { 13 | font-size: 24px; 14 | line-height: 42px; 15 | margin: 20px 0; 16 | } 17 | 18 | .btns { 19 | .flex(); 20 | 21 | align-content: flex-start; 22 | background-repeat: no-repeat; 23 | background-size: contain; 24 | flex-wrap: wrap; 25 | height: 428px; 26 | padding: 12px 13px; 27 | 28 | .btn { 29 | .ani-show-right(); 30 | .transition-quick(); 31 | .flex-center(); 32 | 33 | background-repeat: no-repeat; 34 | background-size: contain; 35 | border: 3px solid rgba(73, 83, 102, 0.4); 36 | border-radius: 8px; 37 | height: 44px; 38 | margin: 10px; 39 | width: 200px; 40 | 41 | & > span { 42 | .transition-quick(); 43 | 44 | color: @color-primary; 45 | line-height: $height; 46 | margin-left: 8px; 47 | text-align: center; 48 | } 49 | 50 | &:hover { 51 | transform: translateY(-2px); 52 | border-color: @color-accent-deep; 53 | background-color: rgba(255, 165, 100, 0.3); 54 | 55 | & > span { 56 | color: @color-primary; 57 | } 58 | } 59 | 60 | &:active { 61 | transform: none; 62 | background-color: rgba(255, 165, 100, 0.6); 63 | } 64 | } 65 | 66 | .highlight { 67 | background-color: rgba(255, 165, 100, 0.1); 68 | } 69 | } 70 | 71 | .back-btn { 72 | left: 20px; 73 | position: absolute; 74 | top: 20px; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/render/components/BounceNumber/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | 3 | export interface BounceNumberProp { 4 | number: number 5 | size?: number 6 | duration?: number 7 | style?: React.CSSProperties 8 | wrapperStyle?: React.CSSProperties 9 | } 10 | 11 | const nums: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] 12 | 13 | export default function BounceNumber(props: BounceNumberProp) { 14 | const { number, style = {}, duration = 1, size = 16, wrapperStyle = {} } = props 15 | 16 | const [transforms, setTransforms] = useState([]) 17 | const numbers = String(number).split('') 18 | 19 | useEffect(() => { 20 | const trans = numbers.map((e) => `translateY(-${Number(e) * size}px)`) 21 | setTimeout(() => setTransforms(trans), 20) 22 | }, [numbers, size]) 23 | 24 | const numsStyle: React.CSSProperties = { 25 | display: 'flex', 26 | flexDirection: 'column', 27 | height: `${size}px`, 28 | transition: `all ${duration}s ease-in-out` 29 | } 30 | 31 | return ( 32 |
41 | {numbers.map((e, i) => { 42 | const divStyle = { ...numsStyle, transform: transforms[i] || 'none' } 43 | const spanStyle = { ...style, height: `${size}px`, fontSize: `${size}px` } 44 | return ( 45 |
46 | {nums.map((f) => ( 47 | 48 | {e} 49 | 50 | ))} 51 |
52 | ) 53 | })} 54 |
55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /src/render/components/WeaponCard/index.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import React from 'react' 3 | 4 | import styles from './index.less' 5 | import star1 from '../../../assets/star1.png' 6 | import star2 from '../../../assets/star2.png' 7 | import star3 from '../../../assets/star3.png' 8 | import star4 from '../../../assets/star4.png' 9 | import star5 from '../../../assets/star5.png' 10 | 11 | import type { MouseEventHandler } from 'react' 12 | 13 | type CardWeaponInfo = { 14 | affix_level: number 15 | desc: string 16 | icon: string 17 | level: number 18 | name: string 19 | promote_level: number 20 | rarity: number 21 | type_name: string 22 | } 23 | 24 | export interface WeaponCardProp { 25 | className?: string 26 | withName?: boolean 27 | onClick?: MouseEventHandler 28 | weapon: CardWeaponInfo 29 | style?: React.CSSProperties 30 | } 31 | 32 | const StarImgs: string[] = [star1, star2, star3, star4, star5] 33 | 34 | export default function WeaponCard(props: WeaponCardProp) { 35 | const { className, onClick, weapon, style, withName = true } = props 36 | 37 | const getStarClass = (rarity: number) => styles[`star${rarity > 5 ? 6 : rarity}`] 38 | const getStarImage = (rarity: number) => StarImgs[(rarity > 5 ? 5 : rarity) - 1] 39 | 40 | return ( 41 |
42 |
43 | 44 | 45 |
{weapon.level}
46 | {weapon.affix_level > 0 &&
{weapon.affix_level}
} 47 | {withName && {weapon.name}} 48 | {/* Lv. {weapon.level} */} 49 |
50 |
51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /src/render/pages/login/index.less: -------------------------------------------------------------------------------- 1 | @import '../../utils/colors.less'; 2 | @import '../../utils/utils.less'; 3 | 4 | .container { 5 | .ani-show-right(); 6 | .flex-center-x-column(); 7 | 8 | flex: 1; 9 | position: relative; 10 | 11 | .top { 12 | .ani-show-top(); 13 | .flex-center(); 14 | 15 | width: 100%; 16 | height: 82px; 17 | background-color: @color-bg; 18 | position: relative; 19 | 20 | .title { 21 | font-size: 24px; 22 | } 23 | } 24 | 25 | .content { 26 | .flex-center-x-column(); 27 | 28 | flex: 1; 29 | justify-content: flex-start; 30 | padding: 0 30px 20px 30px; 31 | 32 | & > div:nth-child(1) { 33 | margin-top: 20px; 34 | 35 | & > div:nth-child(1) { 36 | font-size: 20px; 37 | margin-bottom: 40px; 38 | } 39 | } 40 | 41 | & > div > span { 42 | color: @color-accent; 43 | display: block; 44 | margin-top: 16px; 45 | } 46 | 47 | .step { 48 | margin-bottom: 6px; 49 | } 50 | 51 | .btns { 52 | .flex-center-y(); 53 | 54 | justify-content: space-evenly; 55 | margin: 60px 0; 56 | width: 50vw; 57 | } 58 | 59 | & > img { 60 | margin: 0 0 20px 0; 61 | } 62 | 63 | .local-user { 64 | .ani-show-bottom(); 65 | .flex-center-column(); 66 | 67 | width: 90vw; 68 | overflow: hidden; 69 | 70 | & > div:nth-child(2) { 71 | .flex-center-y(); 72 | 73 | width: 100%; 74 | margin: 12px 0 20px 0; 75 | 76 | & > div { 77 | margin: 2px 6px; 78 | min-width: 76px; 79 | width: 76px; 80 | } 81 | } 82 | } 83 | } 84 | 85 | .back-btn { 86 | left: 20px; 87 | position: absolute; 88 | top: 20px; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/main/index.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow } from 'electron' 2 | 3 | import { createMainWindow } from './createMainWindow' 4 | import { unregisterHotkey } from './handleHotkeys' 5 | import { initStore } from './initStore' 6 | 7 | import type { AppData } from '../typings' 8 | import type Store from 'electron-store' 9 | 10 | // 导出 主窗口 与 Store 方便其他部分进行引用 11 | 12 | // 在外层定义主窗口,并导出,方便其他子窗口创建时进行引用 13 | export let mainWin: BrowserWindow = null 14 | // Store 用于存储与恢复软件数据(配置、状态等) 15 | export let store: Store 16 | 17 | // 禁用硬件加速 18 | app.disableHardwareAcceleration() 19 | 20 | // 单例模式 21 | const isWinner = app.requestSingleInstanceLock() 22 | 23 | // 用以代表开发模式的变量,导出以供其他部分引用 24 | export const isDev = !app.isPackaged 25 | // Windows 26 | export const isWindows = process.platform === 'win32' 27 | // macOS 28 | export const isAppleDevice = process.platform === 'darwin' 29 | 30 | // 如果不是第一个实例,直接退出 31 | if (!isWinner) { 32 | app.quit() 33 | } 34 | 35 | // 以下是第一个实例的逻辑,监听第二实例的启动事件 36 | 37 | // 检测到第二次启动的时候,若第一个实例窗口未关闭,则前置显示第一个实例,不再重复创建 38 | app.on('second-instance', () => mainWin?.show()) 39 | 40 | // 程序准备完毕的事件 41 | app.on('ready', () => { 42 | // 隐藏 dock 图标 43 | if (isAppleDevice) { 44 | app.dock.hide() 45 | } 46 | // 初始化 Store (读取配置) 47 | store = initStore() 48 | // 创建主窗口 49 | mainWin = createMainWindow() 50 | }) 51 | 52 | // 监听窗口全部关闭的事件 53 | app.on('window-all-closed', () => { 54 | // 不是苹果设备则退出 55 | if (!isAppleDevice) { 56 | app.quit() 57 | } 58 | }) 59 | 60 | // 监听程序激活事件 61 | app.on('activate', () => { 62 | const windowExist = BrowserWindow.getAllWindows().length !== 0 63 | if (windowExist) { 64 | return 65 | } 66 | // 如果不存在任何窗口,则创建主窗口 67 | mainWin = createMainWindow() 68 | }) 69 | 70 | // 监听程序退出的事件,善后,取消注册全局热键 71 | app.on('before-quit', () => isWindows && unregisterHotkey()) 72 | -------------------------------------------------------------------------------- /src/render/pages/gacha/Statistics/components/DateRange.tsx: -------------------------------------------------------------------------------- 1 | import { ResponsiveTimeRange } from '@nivo/calendar' 2 | import React from 'react' 3 | 4 | import { ChartTheme } from '../../../../../constants' 5 | 6 | import type { CalendarDatum, CalendarLegendProps, TimeRangeDayData } from '@nivo/calendar' 7 | 8 | type DateRangeProp = { 9 | width: React.CSSProperties['width'] 10 | height: React.CSSProperties['height'] 11 | style?: React.CSSProperties 12 | className?: string 13 | onClick?: (datum: TimeRangeDayData, event: React.MouseEvent) => void 14 | data: CalendarDatum[] 15 | range: (Date | string)[] 16 | } 17 | 18 | const legends: CalendarLegendProps[] = [ 19 | { 20 | anchor: 'bottom', 21 | direction: 'row', 22 | itemCount: 4, 23 | itemHeight: 20, 24 | itemsSpacing: 32, 25 | itemWidth: 24, 26 | translateX: 32, 27 | translateY: -36 28 | } 29 | ] 30 | 31 | export default function DateRange(props: DateRangeProp) { 32 | const { data, range, style, width, height, onClick, className = '' } = props 33 | 34 | return ( 35 |
45 | `${e}次`} 47 | onClick={onClick} 48 | colors={['#FFEEE1', '#FFDFC8', '#FFCEAA', '#FFA564', '#FF9142']} 49 | data={data} 50 | dayBorderColor='#fff' 51 | dayBorderWidth={2} 52 | dayRadius={5} 53 | emptyColor='#efefef' 54 | from={range[0]} 55 | legends={legends} 56 | theme={ChartTheme} 57 | to={range[1]} 58 | weekdayTicks={[]} 59 | /> 60 |
61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /src/utils/updateLocalGachaData.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron' 2 | import fs from 'fs-extra' 3 | import path from 'node:path' 4 | 5 | import { mergeGachaList } from './mergeGachaList' 6 | import { DefaultGachaData } from '../services/getGachaListByUrl' 7 | 8 | import type { GachaData } from '../typings' 9 | 10 | // 通过新的抽卡数据来更新配置文件里的抽卡数据 11 | export function updateLocalGachaData(gacha: GachaData) { 12 | // 获取新的数据的 UID 13 | const { uid } = gacha.info 14 | 15 | // 获取当前的软件目录 16 | const AppPath = app.getPath('userData') 17 | // 获取存放所有祈愿数据的文件夹路径 18 | const GachaDataDirPath = path.join(AppPath, 'GachaDatas') 19 | 20 | // 若该文件夹不存在,则创建 21 | if (!fs.existsSync(GachaDataDirPath)) { 22 | fs.mkdirSync(GachaDataDirPath) 23 | } 24 | 25 | // 获取该 UID 的数据文件路径 26 | const GachaFilePath = path.join(GachaDataDirPath, `${uid}.json`) 27 | const isNewData = !fs.existsSync(GachaFilePath) 28 | 29 | if (isNewData) { 30 | // 如果该 UID 数据不存在,则说明是第一次获取 31 | 32 | // 预处理数据(排序等) 33 | const list = mergeGachaList([], gacha.list) 34 | const data = { info: gacha.info, list } 35 | 36 | // 写入本地文件 37 | fs.writeFileSync(GachaFilePath, JSON.stringify(data)) 38 | 39 | // 直接返回参数里的祈愿数据 40 | return data 41 | } 42 | // 如果该 UID 数据存在,则先读取旧数据,然后做合并处理 43 | try { 44 | // 读取旧数据 45 | const LocalGachaStr = fs.readFileSync(GachaFilePath, { encoding: 'utf-8' }) 46 | const LocalGacha = JSON.parse(LocalGachaStr) as GachaData 47 | 48 | // 合并处理 49 | const list = mergeGachaList(LocalGacha.list, gacha.list) 50 | 51 | // 写入新数据 52 | const data = { info: gacha.info, list } 53 | const fileContent = JSON.stringify(data) 54 | fs.writeFileSync(GachaFilePath, fileContent) 55 | 56 | // 返回合并后的祈愿数据 57 | return data 58 | } catch { 59 | // JSON 解析出错时,返回空数据 60 | return DefaultGachaData 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/IPC/openGame.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron' 2 | import fs from 'fs-extra' 3 | import cp from 'node:child_process' 4 | import path from 'node:path' 5 | import util from 'node:util' 6 | 7 | import { getGameDir } from '../../utils/getGameDir' 8 | 9 | const exec = util.promisify(cp.exec) 10 | 11 | const presetDirs = [ 12 | 'C:\\Program Files\\Genshin Impact\\Genshin Impact Game', 13 | 'C:\\Genshin Impact\\Genshin Impact Game', 14 | 'D:\\Program Files\\Genshin Impact\\Genshin Impact Game', 15 | 'D:\\Genshin Impact\\Genshin Impact Game', 16 | 'E:\\Program Files\\Genshin Impact\\Genshin Impact Game', 17 | 'E:\\Genshin Impact\\Genshin Impact Game', 18 | 'F:\\Program Files\\Genshin Impact\\Genshin Impact Game', 19 | 'F:\\Genshin Impact\\Genshin Impact Game', 20 | 'G:\\Program Files\\Genshin Impact\\Genshin Impact Game', 21 | 'G:\\Genshin Impact\\Genshin Impact Game' 22 | ] 23 | 24 | /** 本地启动游戏 */ 25 | export async function openGame() { 26 | // 系统语言 27 | const lang = app.getLocale() 28 | // 游戏本体可执行程序名 29 | const name = lang.includes('zh') ? 'YuanShen.exe' : 'GenshinImpact.exe' 30 | 31 | // 游戏安装目录 32 | let gameDir = getGameDir() 33 | 34 | if (!gameDir) { 35 | // 如果找不到游戏安装目录,尝试这几个默认位置 36 | presetDirs.forEach((dir) => { 37 | if (fs.existsSync(path.join(dir, name))) { 38 | console.log('dir found in preset:', gameDir) 39 | gameDir = dir 40 | } 41 | }) 42 | } 43 | 44 | if (!gameDir) { 45 | return { 46 | code: -1, 47 | data: null, 48 | message: '原神安装目录检测失败,请先尝试打开一次祈愿历史记录页' 49 | } 50 | } 51 | 52 | try { 53 | console.log('exec:', path.join(gameDir, name)) 54 | exec(name, { cwd: gameDir }) 55 | } catch (e) { 56 | console.log(e) 57 | } 58 | 59 | return { 60 | code: 0, 61 | data: true, 62 | message: '原神正在启动中...' 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/services/getMonthInfo.ts: -------------------------------------------------------------------------------- 1 | import { API_HK4E, LINK_BBS_REFERER } from '../constants' 2 | import { getCurrentUser } from '../main/IPC/getCurrentUser' 3 | import { getServerByUid } from '../utils/getServerByUid' 4 | import { request } from '../utils/request' 5 | 6 | import type { BaseRes } from '../typings' 7 | 8 | interface DayData { 9 | current_primogems: number 10 | current_mora: number 11 | last_primogems: number 12 | last_mora: number 13 | } 14 | 15 | interface GroupBy { 16 | action_id: number 17 | action: string 18 | num: number 19 | percent: number 20 | } 21 | 22 | interface MonthData { 23 | current_primogems: number 24 | current_mora: number 25 | last_primogems: number 26 | last_mora: number 27 | current_primogems_level: number 28 | primogems_rate: number 29 | mora_rate: number 30 | group_by: GroupBy[] 31 | } 32 | 33 | export interface MonthInfo { 34 | uid: number 35 | region: string 36 | account_id: number 37 | nickname: string 38 | date: string 39 | month: number 40 | optional_month: number[] 41 | data_month: number 42 | data_last_month: number 43 | day_data: DayData 44 | month_data: MonthData 45 | lantern: boolean 46 | } 47 | 48 | export async function getMonthInfo(month = 0) { 49 | const currentUser = getCurrentUser() 50 | 51 | if (!currentUser) { 52 | return null 53 | } 54 | 55 | const { cookie, uid } = currentUser 56 | const url = `${API_HK4E}/event/ys_ledger/monthInfo` 57 | const params = { month, bind_uid: uid, bind_region: getServerByUid(uid) } 58 | const headers = { referer: LINK_BBS_REFERER, cookie } 59 | const config = { params, headers } 60 | 61 | const { status, data } = await request.get>(url, config) 62 | 63 | if (status !== 200 || data?.retcode !== 0) { 64 | console.log('getMonthInfo: ', data) 65 | } 66 | 67 | return data 68 | } 69 | -------------------------------------------------------------------------------- /src/main/IPC/getLocalGachaData.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron' 2 | import fs from 'fs-extra' 3 | import path from 'node:path' 4 | 5 | import { AppName } from '../../constants' 6 | 7 | import type { GachaData } from '../../typings' 8 | 9 | // 尝试获取本地所有祈愿数据 10 | export function getLocalGachaData() { 11 | // 获取当前的软件目录 12 | const AppPath = app.getPath('userData') 13 | // 获取存放所有祈愿数据的目录 14 | const gachaDataDirPath = path.join(AppPath, 'GachaDatas') 15 | // 若该目录不存在 16 | if (!fs.existsSync(gachaDataDirPath)) { 17 | // 旧版的祈愿数据目录 18 | const fallbackPath = path.join(AppPath.replace(app.getName(), AppName.zh), 'GachaDatas') 19 | // 判断旧版的目录是否存在 20 | if (fs.existsSync(fallbackPath)) { 21 | // 如果存在则迁移 22 | fs.cpSync(`${fallbackPath}/`, `${gachaDataDirPath}/`, { force: true, recursive: true }) 23 | } else { 24 | // 如不存在则创建祈愿数据的目录,并返回空数据 25 | fs.mkdirSync(gachaDataDirPath) 26 | return [] 27 | } 28 | } 29 | // 尝试获取存放祈愿数据的目录下所有的数据 30 | const res = fs.readdirSync(gachaDataDirPath, { withFileTypes: true }) 31 | // 待处理数据文件名列表 32 | const gachaFiles: string[] = [] 33 | // 遍历存放祈愿数据的目录下所有的内容 34 | res.forEach((e) => { 35 | // 如果是 json 文件,且名字符合 36 | if (e.isFile && e.name.match(/^[0-9]{8,10}.json$/)) { 37 | // 将文件名加入待处理数据文件名列表 38 | gachaFiles.push(e.name) 39 | } 40 | }) 41 | 42 | // 最终返回的数据 43 | const gachas: GachaData[] = [] 44 | // 遍历待处理数据文件名列表 45 | gachaFiles.forEach((filename) => { 46 | // 拼接文件路径 47 | const filePath = path.join(gachaDataDirPath, filename) 48 | // 读取 JSON 文件内容 49 | const content = fs.readFileSync(filePath, { encoding: 'utf-8' }) 50 | try { 51 | // 尝试解析,并将成功解析的数据存入列表 52 | const data = JSON.parse(content) 53 | gachas.push(data) 54 | } catch (e: any) { 55 | console.log(e) 56 | } 57 | }) 58 | 59 | // 返回数据 60 | return gachas 61 | } 62 | -------------------------------------------------------------------------------- /src/utils/verifyCookieAndGetGameRole.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentRole } from './getCurrentRole' 2 | import { request } from './request' 3 | import { API_TAKUMI, GAME_BIZ, LINK_BBS_REFERER } from '../constants' 4 | 5 | import type { GameRole, BaseRes, GameRolesData } from '../typings' 6 | import type { Cookies } from 'electron' 7 | 8 | export interface AuthResState { 9 | cookie: string 10 | valid: boolean 11 | roleInfo: GameRole | null 12 | } 13 | 14 | /** 将 Cookies 类转为 cookie 字符串的函数 */ 15 | async function transferCookiesToString(cookies: Cookies) { 16 | // 获取所有 Cookie 17 | const cks = await cookies.get({}) 18 | // 拼接 Cookie 19 | return cks.reduce((p, n) => `${p}${n.name}=${n.value}; `, '').trim() 20 | } 21 | 22 | /** 通过 Cookie 获取绑定的角色信息列表 */ 23 | async function getUserRolesByCookie(cookie: string): Promise { 24 | const url = `${API_TAKUMI}/binding/api/getUserGameRolesByCookie` 25 | const config = { params: { game_biz: GAME_BIZ }, headers: { referer: LINK_BBS_REFERER, cookie } } 26 | const { data, status } = await request.get>(url, config) 27 | 28 | if (status !== 200 || data.retcode !== 0) { 29 | console.log('getUserRolesByCookie: ', data) 30 | } 31 | 32 | return data?.data?.list || null 33 | } 34 | 35 | /** 验证 Cookie 有效性并尝试获取绑定的游戏角色 */ 36 | export async function verifyCookieAndGetGameRole(cks: Cookies) { 37 | const cookie = await transferCookiesToString(cks) 38 | const hasLtoken = cookie.includes('ltoken') 39 | 40 | if (!hasLtoken) { 41 | return { valid: false, cookie: '', roleInfo: null } 42 | } 43 | 44 | const roles = await getUserRolesByCookie(cookie) 45 | const chosenRole = getCurrentRole(roles) 46 | 47 | const valid = chosenRole?.game_uid 48 | 49 | if (!valid) { 50 | console.log('verifyCookie: ', cookie) 51 | } 52 | 53 | return { valid: true, cookie, roleInfo: valid ? chosenRole : null } 54 | } 55 | -------------------------------------------------------------------------------- /src/services/getGameRecordCard.ts: -------------------------------------------------------------------------------- 1 | import { API_TAKUMI_RECORD, LINK_BBS_REFERER } from '../constants' 2 | import { getCurrentUser } from '../main/IPC/getCurrentUser' 3 | import { getDS } from '../utils/getDS' 4 | import { request } from '../utils/request' 5 | import { qs } from '../utils/utils' 6 | 7 | import type { BaseRes } from '../typings' 8 | 9 | export interface Data { 10 | name: string 11 | type: number 12 | value: string 13 | } 14 | 15 | export interface Data_switches { 16 | switch_name: string 17 | switch_id: number 18 | is_public: boolean 19 | } 20 | 21 | export interface GameRecordCardItem { 22 | region_name: string 23 | game_id: number 24 | is_public: boolean 25 | h5_data_switches: any[] 26 | url: string 27 | level: number 28 | has_role: boolean 29 | data: Data[] 30 | region: string 31 | data_switches: Data_switches[] 32 | game_role_id: string 33 | nickname: string 34 | background_image: string 35 | } 36 | 37 | export type GameRecordCardData = GameRecordCardItem[] 38 | 39 | export interface GameRecordCardRawData { 40 | list: GameRecordCardData 41 | } 42 | 43 | export async function getGameRecordCard(bbsId?: string) { 44 | const currentUser = getCurrentUser() 45 | 46 | if (!currentUser) { 47 | return null 48 | } 49 | 50 | const targetBbsId = bbsId || currentUser.uid 51 | 52 | const url = `${API_TAKUMI_RECORD}/game_record/app/card/wapi/getGameRecordCard` 53 | const params = { uid: targetBbsId } 54 | const headers = { 55 | referer: LINK_BBS_REFERER, 56 | DS: getDS(qs(params)), 57 | cookie: currentUser.cookie 58 | } 59 | 60 | const config = { 61 | headers, 62 | params 63 | } 64 | 65 | const { status, data } = await request.get>(url, config) 66 | 67 | if (status !== 200 || data?.retcode !== 0) { 68 | console.log('getGameRecordCard: ', data) 69 | } 70 | 71 | return data 72 | } 73 | -------------------------------------------------------------------------------- /src/main/createMainWindow.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, shell } from 'electron' 2 | 3 | import { registerHotkey } from './handleHotkeys' 4 | import { initTray } from './initTray' 5 | import { bindIPC } from './IPC' 6 | import { restoreSettings } from './restoreSettings' 7 | import icon from '../assets/icon.ico' 8 | 9 | import type { BrowserWindowConstructorOptions } from 'electron' 10 | 11 | // 声明内置常量 12 | declare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: string 13 | declare const MAIN_WINDOW_WEBPACK_ENTRY: string 14 | 15 | /** 配置窗口的选项参数 */ 16 | const winOptions: BrowserWindowConstructorOptions = { 17 | // 设置窗口默认的宽度、高度 18 | width: 970, 19 | height: 600, 20 | show: false, 21 | // 无边框窗口(自绘边框) 22 | frame: false, 23 | // 不可手动调整大小 24 | resizable: false, 25 | // 窗口 icon 26 | icon, 27 | // 禁止最大化 28 | maximizable: false, 29 | // 禁止全屏 30 | fullscreenable: false, 31 | // 加载时的背景颜色 32 | backgroundColor: '#F9F6F2', 33 | // 设置 web 页面的 preload,用于 IPC 通信 34 | webPreferences: { preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY } 35 | } 36 | 37 | /** 创建主窗口的函数 */ 38 | export function createMainWindow() { 39 | const win = new BrowserWindow(winOptions) 40 | 41 | // 移除窗口顶部的默认菜单栏 42 | win.removeMenu() 43 | // 监听准备好了的事件,当就绪时显示主窗口 44 | win.once('ready-to-show', () => win.show()) 45 | // 阻止窗口边框右键单击 46 | win.once('system-context-menu', (e) => e.preventDefault()) 47 | 48 | // 处理跳转,默认使用外部浏览器打开(比如 target 为 _blank 的 a 链接) 49 | win.webContents.setWindowOpenHandler((details) => { 50 | shell.openExternal(details.url) 51 | return { action: 'deny' } 52 | }) 53 | 54 | // 加载入口文件,这个入口常量是由 electron-forge 和 webpack 内置的 55 | win.loadURL(MAIN_WINDOW_WEBPACK_ENTRY) 56 | 57 | // 注册 IPC 事件(用于 main 进程与 render 进程安全通信) 58 | bindIPC(win) 59 | // 初始化托盘图标与菜单 60 | initTray(win) 61 | // 恢复设置 62 | restoreSettings(win) 63 | // 注册全局热键 64 | registerHotkey(win) 65 | 66 | // 返回创建的窗口实例 67 | return win 68 | } 69 | -------------------------------------------------------------------------------- /src/services/getDailyNotes.ts: -------------------------------------------------------------------------------- 1 | import { API_TAKUMI_RECORD, LINK_BBS_REFERER } from '../constants' 2 | import { getCurrentUser } from '../main/IPC/getCurrentUser' 3 | import { getDS } from '../utils/getDS' 4 | import { getServerByUid } from '../utils/getServerByUid' 5 | import { request } from '../utils/request' 6 | import { qs } from '../utils/utils' 7 | 8 | import type { BaseRes } from '../typings' 9 | 10 | export type DispatchItem = { 11 | avatar_side_icon: string 12 | remained_time: string 13 | status: string 14 | } 15 | 16 | export type DailyNotesData = { 17 | current_expedition_num: number 18 | current_home_coin: number 19 | current_resin: number 20 | expeditions: DispatchItem[] 21 | finished_task_num: number 22 | home_coin_recovery_time: string 23 | is_extra_task_reward_received: boolean 24 | max_expedition_num: number 25 | max_home_coin: number 26 | max_resin: number 27 | remain_resin_discount_num: number 28 | resin_discount_num_limit: number 29 | resin_recovery_time: string 30 | total_task_num: number 31 | transformer: { 32 | obtained: boolean 33 | recovery_time: { 34 | Day: number 35 | Hour: number 36 | Minute: number 37 | Second: number 38 | reached: true 39 | } 40 | } 41 | } 42 | 43 | export async function getDailyNotes() { 44 | const currentUser = getCurrentUser() 45 | 46 | if (!currentUser) { 47 | return null 48 | } 49 | 50 | const { cookie, uid } = currentUser 51 | const url = `${API_TAKUMI_RECORD}/game_record/app/genshin/api/dailyNote` 52 | 53 | const params = { role_id: uid, server: getServerByUid(uid) } 54 | const headers = { referer: LINK_BBS_REFERER, cookie, DS: getDS(qs(params)) } 55 | 56 | const { status, data } = await request.get>(url, { 57 | params, 58 | headers 59 | }) 60 | 61 | // { data: null, message: 'Please login', retcode: 10001 } 62 | if (status !== 200 || data?.retcode !== 0) { 63 | console.log('getDailyNotes: ', data) 64 | } 65 | 66 | return data 67 | } 68 | -------------------------------------------------------------------------------- /src/render/pages/sign/index.less: -------------------------------------------------------------------------------- 1 | @import '../../utils/colors.less'; 2 | @import '../../utils/utils.less'; 3 | 4 | .container { 5 | .ani-show-right(); 6 | .flex-center-x(); 7 | 8 | flex: 1; 9 | position: relative; 10 | 11 | .signContainer { 12 | .flex-center-x-column(); 13 | 14 | .title { 15 | .ani-show-top(); 16 | 17 | font-size: 24px; 18 | line-height: 42px; 19 | margin-top: 20px; 20 | } 21 | 22 | .tip { 23 | .ani-show-top(); 24 | 25 | color: @color-second; 26 | } 27 | 28 | .sign-table { 29 | .ani-show-right(); 30 | .flex-wrap(); 31 | 32 | align-content: flex-start; 33 | height: 432px; 34 | margin: 16px 0; 35 | width: 860px; 36 | 37 | .sign-item { 38 | .flex-column(); 39 | .transition(); 40 | 41 | background-image: url('../../../assets/sign-item-bg.png'); 42 | background-repeat: no-repeat; 43 | background-size: contain; 44 | border: 1px solid @color-second; 45 | border-radius: 4px; 46 | height: 100px; 47 | margin: 3px; 48 | width: 78px; 49 | 50 | &:hover { 51 | transform: scale(1.05); 52 | } 53 | 54 | img { 55 | width: 64px; 56 | margin: calc((78px - $width) / 2); 57 | height: $width; 58 | } 59 | 60 | div { 61 | text-align: center; 62 | line-height: calc(100px - 78px); 63 | font-size: 10px; 64 | } 65 | } 66 | 67 | .today { 68 | background-image: url('../../../assets/sign-item-bg-today.png'); 69 | 70 | &:hover { 71 | transform: scale(1.1); 72 | } 73 | } 74 | 75 | .signed { 76 | &::before { 77 | background: no-repeat url('../../../assets/sign-item-signed.png'); 78 | background-size: contain; 79 | content: ''; 80 | height: 100px; 81 | position: absolute; 82 | width: 78px; 83 | } 84 | } 85 | } 86 | } 87 | 88 | .back-btn { 89 | left: 20px; 90 | position: absolute; 91 | top: 20px; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/render/pages/setting/About/index.less: -------------------------------------------------------------------------------- 1 | @import '../../../utils/colors.less'; 2 | @import '../../../utils/utils.less'; 3 | 4 | .container { 5 | .ani-show-right(); 6 | .flex-center-x-column(); 7 | 8 | flex: 1; 9 | 10 | .declaration { 11 | .ani-show-top(); 12 | 13 | line-height: 1.6rem; 14 | width: 86%; 15 | 16 | p { 17 | text-indent: 2rem; 18 | margin: 8px 0 10px 0; 19 | } 20 | } 21 | 22 | .thank { 23 | color: @color-second; 24 | } 25 | 26 | .bottom { 27 | .ani-show-bottom(); 28 | 29 | align-items: flex-end; 30 | display: flex; 31 | justify-content: space-between; 32 | margin: 4px 0 20px 0; 33 | width: 86%; 34 | 35 | .items { 36 | .flex-column(); 37 | 38 | height: 100%; 39 | flex: 3; 40 | justify-content: space-evenly; 41 | 42 | .item { 43 | .flex-center-y(); 44 | 45 | span { 46 | margin-left: 12px; 47 | } 48 | 49 | a { 50 | margin-left: 6px; 51 | margin-right: 4px; 52 | } 53 | 54 | span:nth-child(2) { 55 | // .ani-show-right(); 56 | 57 | margin: 0; 58 | } 59 | 60 | a:nth-child(3) { 61 | .ani-show-right(); 62 | 63 | margin-left: 4px; 64 | } 65 | } 66 | } 67 | 68 | .code-zones { 69 | .flex(); 70 | 71 | flex: 2; 72 | position: relative; 73 | 74 | .code-zone { 75 | .flex-center-column(); 76 | 77 | flex: 1; 78 | z-index: 1; 79 | 80 | .code { 81 | border: 1px solid rgba(200, 200, 200, 0.3); 82 | border-radius: 8px; 83 | height: $width; 84 | margin-bottom: 12px; 85 | transition: all 0.5s cubic-bezier(0.18, 0.6, 0.27, 1); 86 | width: 120px; 87 | 88 | &:hover { 89 | .shadow(); 90 | 91 | cursor: none; 92 | z-index: 10; 93 | transform: scale(1.5); 94 | } 95 | } 96 | 97 | div { 98 | font-size: 12px; 99 | color: @color-second; 100 | } 101 | } 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/render/components/ItemCard/index.less: -------------------------------------------------------------------------------- 1 | @import '../../utils/colors.less'; 2 | @import '../../utils/utils.less'; 3 | 4 | .wrapper { 5 | .flex-center-column(); 6 | .transition(); 7 | 8 | color: #564c4a; 9 | 10 | &:hover { 11 | transform: scale(1.05); 12 | } 13 | 14 | & > div { 15 | .flex-center-x-column(); 16 | .shadow-deep(); 17 | 18 | border-radius: 6px; 19 | margin-bottom: 3px; 20 | position: relative; 21 | width: 80px; 22 | 23 | // 物品图 24 | img:nth-child(1) { 25 | margin-top: 2px; 26 | width: 80px; 27 | height: 80px; 28 | } 29 | 30 | // 物品星级 31 | img:nth-child(2) { 32 | bottom: 14px; 33 | position: absolute; 34 | height: 20px; 35 | width: auto; 36 | } 37 | 38 | .corner-number { 39 | .flex-center(); 40 | 41 | background-color: rgba(64, 64, 64, 0.6); 42 | position: absolute; 43 | color: @color-white; 44 | font-size: 10px; 45 | height: 20px; 46 | width: 20px; 47 | } 48 | 49 | // 物品等级 50 | & > div { 51 | .corner-number(); 52 | 53 | border-top-left-radius: 6px; 54 | border-bottom-right-radius: 4px; 55 | left: 0px; 56 | top: 0px; 57 | } 58 | 59 | // 物品名称 60 | & > span { 61 | font-size: 10px; 62 | line-height: 15px; 63 | text-shadow: none; 64 | } 65 | } 66 | } 67 | 68 | .star-1 { 69 | .bg-image-cover-no-repeat(); 70 | 71 | background-image: url('../../../assets/item-bg-1.png'); 72 | } 73 | 74 | .star-2 { 75 | .bg-image-cover-no-repeat(); 76 | 77 | background-image: url('../../../assets/item-bg-2.png'); 78 | } 79 | 80 | .star-3 { 81 | .bg-image-cover-no-repeat(); 82 | 83 | background-image: url('../../../assets/item-bg-3.png'); 84 | } 85 | 86 | .star-4 { 87 | .bg-image-cover-no-repeat(); 88 | 89 | background-image: url('../../../assets/item-bg-4.png'); 90 | } 91 | 92 | .star-5 { 93 | .bg-image-cover-no-repeat(); 94 | 95 | background-image: url('../../../assets/item-bg-5.png'); 96 | } 97 | 98 | .star-6 { 99 | .bg-image-cover-no-repeat(); 100 | 101 | background-image: url('../../../assets/item-bg-6.png'); 102 | } 103 | -------------------------------------------------------------------------------- /src/render/pages/calendar/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { TiArrowBack } from 'react-icons/ti' 3 | import { useNavigate } from 'react-router-dom' 4 | 5 | import DailyMaterial from './DailyMaterial' 6 | import styles from './index.less' 7 | import WeekMaterial from './WeekMaterial' 8 | import CircleButton from '../../components/CircleButton' 9 | import Loading from '../../components/Loading' 10 | import SelectButton from '../../components/SelectButton' 11 | import useApi from '../../hooks/useApi' 12 | import useMount from '../../hooks/useMount' 13 | import useNotice from '../../hooks/useNotice' 14 | import nativeApi from '../../utils/nativeApi' 15 | 16 | import type { RepoRole } from './WeekMaterial' 17 | import type { CalendarData } from '../../../services/getCalendarList' 18 | import type { BaseRes } from '../../../typings' 19 | 20 | export default function Calendar() { 21 | const navigate = useNavigate() 22 | const notice = useNotice() 23 | const [tab, setTab] = useState<'daily' | 'week'>('daily') 24 | 25 | const { r: fetchCal, d: cals, l: l1 } = useApi>(nativeApi.getCalendarEvents) 26 | const { r: fetchRepo, d: roles, l: l2 } = useApi(nativeApi.getRepoData) 27 | 28 | const loaded = !l1 && !l2 29 | 30 | useMount(async () => { 31 | await fetchCal() 32 | await fetchRepo('roles.json') 33 | }) 34 | 35 | const items = [ 36 | { label: '日常材料', value: 'daily' }, 37 | { label: '周本材料', value: 'week' } 38 | ] 39 | 40 | const { list } = cals?.data ?? {} 41 | 42 | const main = ( 43 | <> 44 |
45 | 46 |
47 | {tab === 'daily' && } 48 | {tab === 'week' && } 49 | 50 | ) 51 | 52 | return ( 53 | <> 54 |
55 | {loaded && list && roles ? main : } 56 | 57 | navigate('/')} 62 | /> 63 |
64 | {notice.holder} 65 | 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /src/main/IPC/loginByBBS.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, session } from 'electron' 2 | 3 | import { mainWin, store, isDev, isAppleDevice } from '..' 4 | import { APP_USER_AGENT_MOBILE, LINK_MIHOYO_BBS_LOGIN } from '../../constants' 5 | import { verifyCookieAndGetGameRole } from '../../utils/verifyCookieAndGetGameRole' 6 | 7 | import type { UserData } from '../../typings' 8 | 9 | /** 通过米游社登录的函数,会打开一个窗口用于登录 */ 10 | export async function loginByBBS() { 11 | // 配置窗口参数,默认为手机版本登录样式 12 | const bbsWin = new BrowserWindow({ 13 | width: 400, 14 | height: 700, 15 | show: true, 16 | modal: !isAppleDevice, 17 | parent: mainWin, 18 | resizable: false, 19 | maximizable: false, 20 | alwaysOnTop: true, 21 | fullscreenable: false, 22 | autoHideMenuBar: true, 23 | backgroundColor: '#F9F6F2' 24 | }) 25 | 26 | // 生产环境下移除窗口顶部的默认菜单 27 | if (!isDev) { 28 | bbsWin.removeMenu() 29 | } 30 | 31 | const dom = bbsWin.webContents 32 | 33 | // 阻止弹出新窗口 34 | dom.setWindowOpenHandler(() => ({ action: 'deny' })) 35 | // 设置 UA 为手机版本 36 | dom.setUserAgent(APP_USER_AGENT_MOBILE) 37 | // 加载米游社登录页面 38 | dom.loadURL(LINK_MIHOYO_BBS_LOGIN) 39 | 40 | // 监听登录窗口被关闭事件 41 | bbsWin.on('close', async () => { 42 | // 获取 cookie 43 | const { cookies } = session.defaultSession 44 | // 验证 cookie 有效性(是否成功登录) 45 | const { valid, cookie, roleInfo } = await verifyCookieAndGetGameRole(cookies) 46 | // 设置当前 uid,有效登录时 uid 设置正常,未登录则置空 47 | store.set('currentUid', roleInfo ? roleInfo.game_uid : '') 48 | 49 | // Cookie 无效,或者未绑定游戏角色,则不对本地 store 处理 50 | if (!valid || !roleInfo) { 51 | return 52 | } 53 | 54 | // Cookie 有效,且绑定了游戏角色,则继续处理 55 | const user: UserData = { uid: roleInfo.game_uid, cookie } 56 | // 获取本地所有账户 57 | const localUsers = store.get('users') 58 | // 尝试获取新登录的账户在本地的账户列表里的索引(如果存在) 59 | const userIndex = localUsers.map((e) => e.uid).indexOf(user.uid) 60 | 61 | // 如果索引不为-1,那么说明账户已存在,进行更新操作 62 | if (userIndex !== -1) { 63 | // 删除旧的账号信息,加入新的账号信息 64 | localUsers.splice(userIndex, 1, user) 65 | } else { 66 | // 如果索引为 -1 则说明是新号,直接加入本地账户列表 67 | localUsers.push(user) 68 | } 69 | 70 | // 将新的账号信息列表保存至本地磁盘中 71 | store.set('users', localUsers) 72 | // 登录窗口关闭后,聚焦主窗口 73 | mainWin.focus() 74 | }) 75 | } 76 | -------------------------------------------------------------------------------- /src/render/components/RoleCard/index.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import React from 'react' 3 | 4 | import styles from './index.less' 5 | import Anemo from '../../../assets/anemo.png' 6 | import Cryo from '../../../assets/cryo.png' 7 | import Dendro from '../../../assets/dendro.png' 8 | import Electro from '../../../assets/electro.png' 9 | import Geo from '../../../assets/geo.png' 10 | import Hydro from '../../../assets/hydro.png' 11 | import Pyro from '../../../assets/pyro.png' 12 | import star1 from '../../../assets/star1.png' 13 | import star2 from '../../../assets/star2.png' 14 | import star3 from '../../../assets/star3.png' 15 | import star4 from '../../../assets/star4.png' 16 | import star5 from '../../../assets/star5.png' 17 | 18 | import type { MouseEventHandler } from 'react' 19 | 20 | type CardRoleInfo = { 21 | actived_constellation_num: number 22 | element: string 23 | icon: string 24 | level: number 25 | name: string 26 | rarity: number 27 | } 28 | 29 | export interface RoleCardProp { 30 | className?: string 31 | withName?: boolean 32 | withBorder?: boolean 33 | onClick?: MouseEventHandler 34 | role: CardRoleInfo 35 | style?: React.CSSProperties 36 | } 37 | 38 | const StarImgs: string[] = [star1, star2, star3, star4, star5] 39 | const ElementImgs: Record = { 40 | Pyro, 41 | Hydro, 42 | Anemo, 43 | Electro, 44 | Geo, 45 | Cryo, 46 | Dendro 47 | } 48 | 49 | export default function RoleCard({ 50 | className, 51 | onClick, 52 | role, 53 | style, 54 | withBorder = true, 55 | withName = true 56 | }: RoleCardProp) { 57 | const getStarClass = (rarity: number) => styles[`star${rarity > 5 ? 6 : rarity}`] 58 | const getStarImage = (rarity: number) => StarImgs[(rarity > 5 ? 5 : rarity) - 1] 59 | 60 | return ( 61 |
62 |
63 | 64 | 65 | 66 | Lv.{role.level} 67 | {role.actived_constellation_num > 0 &&
{role.actived_constellation_num}
} 68 |
69 | {withName && {role.name}} 70 |
71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /src/render/pages/portal/index.less: -------------------------------------------------------------------------------- 1 | @import '../../utils/colors.less'; 2 | @import '../../utils/utils.less'; 3 | 4 | .container { 5 | .ani-show-right(); 6 | .flex-center-x-column(); 7 | 8 | flex: 1; 9 | position: relative; 10 | padding: 0 40px; 11 | 12 | .title { 13 | .ani-show-top(); 14 | 15 | font-size: 24px; 16 | line-height: 42px; 17 | margin: 20px 0; 18 | } 19 | 20 | .cards { 21 | .ani-show-bottom(); 22 | .flex(); 23 | 24 | align-content: flex-start; 25 | height: 432px; 26 | flex-wrap: wrap; 27 | overflow: auto; 28 | 29 | &::-webkit-scrollbar-thumb { 30 | border-radius: 2px; 31 | background-color: @color-primary; 32 | } 33 | 34 | &::-webkit-scrollbar { 35 | width: 6px; 36 | } 37 | 38 | .card { 39 | .transition(); 40 | .flex-column(); 41 | 42 | word-break: break-word; 43 | overflow: hidden; 44 | max-width: 250px; 45 | margin: 10px; 46 | padding: 12px; 47 | border-radius: 8px; 48 | box-shadow: 0 0 0 1px rgb(0 0 0 / 14%); 49 | 50 | &:hover { 51 | transform: scale(1.03); 52 | background-color: @color-white; 53 | } 54 | 55 | &:active { 56 | transform: none; 57 | background-color: rgba(240, 240, 240, 0.8); 58 | box-shadow: 0 0 0 1px rgb(0 0 0 / 14%); 59 | } 60 | 61 | & > div:first-child { 62 | .flex-center-y(); 63 | 64 | margin-bottom: 4px; 65 | padding-bottom: 4px; 66 | border-bottom: 1px solid rgb(0 0 0 / 14%); 67 | 68 | & > div { 69 | max-width: 220px; 70 | word-break: break-word; 71 | } 72 | } 73 | 74 | & > div:nth-child(2) { 75 | font-size: 12px; 76 | color: @color-second; 77 | } 78 | 79 | img { 80 | border-radius: 3px; 81 | width: 16px; 82 | height: 16px; 83 | margin-right: 4px; 84 | } 85 | } 86 | } 87 | 88 | .tip { 89 | .ani-show-bottom(); 90 | 91 | position: absolute; 92 | bottom: 16px; 93 | align-self: flex-start; 94 | font-size: 14px; 95 | color: @color-second; 96 | } 97 | 98 | .back-btn { 99 | left: 20px; 100 | position: absolute; 101 | top: 20px; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | import type { EXPOSED_API_FROM_ELECTRON } from './constants' 2 | import type { apis } from './preload' 3 | import type { GameRole } from './services/getGameRoleInfo' 4 | 5 | export interface BaseIPCRes { 6 | ok: boolean 7 | data: T | null 8 | message: string 9 | } 10 | 11 | export type GachaType = 'activity' | 'normal' | 'weapon' | 'newer' 12 | export type GachaItemType = 'weapon' | 'role' 13 | export type StarType = 1 | 2 | 3 | 4 | 5 14 | 15 | export interface AppInfo { 16 | name: string 17 | zhName: string 18 | version: string 19 | isBeta: boolean 20 | isDev: boolean 21 | isWindows: boolean 22 | } 23 | 24 | export interface GachaItem { 25 | count: string 26 | gacha_type: string 27 | id: string 28 | item_id: string 29 | item_type: string 30 | name: string 31 | rank_type: string 32 | time: string 33 | uigf_gacha_type: string 34 | } 35 | 36 | export type RawGachaItem = Omit< 37 | GachaItem & { 38 | uid: string 39 | lang: string 40 | }, 41 | 'uigf_gacha_type' 42 | > 43 | export interface GachaData { 44 | info: { 45 | export_app_version: string 46 | export_app: string 47 | export_time: string 48 | export_timestamp: string 49 | update_time: string 50 | lang: string 51 | uid: string 52 | uigf_version: string 53 | } 54 | list: GachaItem[] 55 | } 56 | 57 | export interface BaseRes { 58 | retcode: number 59 | data: T | null 60 | message: string 61 | } 62 | 63 | export interface BaseResWithIsOk extends BaseRes { 64 | isOK: boolean 65 | } 66 | 67 | export interface GameRole { 68 | game_biz: string 69 | game_uid: string 70 | is_chosen: boolean 71 | is_official: boolean 72 | level: number 73 | nickname: string 74 | region_name: string 75 | region: string 76 | } 77 | 78 | export interface GameRolesData { 79 | list: GameRole[] 80 | } 81 | 82 | export interface UserData { 83 | cookie: string 84 | uid: string 85 | } 86 | 87 | export interface AppData { 88 | currentUid: string 89 | users: UserData[] 90 | settings: { 91 | alwaysOnTop: boolean 92 | deviceId: string 93 | gameDir: string 94 | } 95 | } 96 | 97 | export type NativeApi = typeof apis 98 | 99 | export type ElectronWindow = Window & typeof globalThis & { [EXPOSED_API_FROM_ELECTRON]: NativeApi } 100 | -------------------------------------------------------------------------------- /src/render/pages/calendar/DailyMaterial/index.less: -------------------------------------------------------------------------------- 1 | @import '../../../utils/colors.less'; 2 | @import '../../../utils/utils.less'; 3 | 4 | .content { 5 | .flex-center-x-column(); 6 | 7 | height: 428px; 8 | overflow: hidden; 9 | padding: 0 24px 32px 24px; 10 | position: relative; 11 | width: calc(100vw - 48px); 12 | 13 | .btn { 14 | .transition(); 15 | 16 | background-color: @color-bg; 17 | border: 1px solid @color-second; 18 | border-radius: 4px; 19 | color: @color-second; 20 | font-size: 14px; 21 | height: 18px; 22 | margin: 2px; 23 | padding: 1px 6px; 24 | text-align: center; 25 | } 26 | 27 | .active { 28 | color: @color-white; 29 | background-color: @color-accent; 30 | border-color: @color-accent; 31 | } 32 | 33 | .content-top { 34 | .flex-center-y(); 35 | 36 | justify-content: space-between; 37 | margin-bottom: 8px; 38 | width: calc(100% - 20px); 39 | 40 | .weeks, 41 | .types { 42 | .ani-show-top(); 43 | } 44 | 45 | & > div { 46 | .flex-center-y(); 47 | 48 | span { 49 | color: @color-second; 50 | margin: 0 6px; 51 | } 52 | } 53 | } 54 | 55 | .main { 56 | .ani-show-right(); 57 | .flex-wrap(); 58 | 59 | width: 100%; 60 | overflow: auto; 61 | 62 | &::-webkit-scrollbar { 63 | display: none; 64 | } 65 | 66 | & > div { 67 | .flex-center-x-column(); 68 | .transition(); 69 | 70 | border-radius: 3px; 71 | font-size: 13px; 72 | height: 110px; 73 | justify-content: space-around; 74 | margin: 1px 1px; 75 | padding: 2px 1px; 76 | width: 96px; 77 | 78 | &:hover { 79 | // transform: scale(1.05); 80 | background-color: rgba(200, 200, 200, 0.5); 81 | } 82 | 83 | &:active { 84 | background-color: rgba(200, 200, 200, 0.8); 85 | } 86 | 87 | img { 88 | .shadow(); 89 | 90 | width: 84px; 91 | height: $width; 92 | border-radius: 6px; 93 | object-fit: cover; 94 | } 95 | } 96 | } 97 | 98 | .tip { 99 | .ani-show-bottom(); 100 | 101 | bottom: 0; 102 | color: @color-second; 103 | font-size: 14px; 104 | left: 30px; 105 | position: absolute; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/render/pages/strategy/index.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames' 2 | import React from 'react' 3 | import { TiArrowBack } from 'react-icons/ti' 4 | import { useNavigate } from 'react-router-dom' 5 | 6 | import styles from './index.less' 7 | import CircleButton from '../../components/CircleButton' 8 | import Loading from '../../components/Loading' 9 | import useApi from '../../hooks/useApi' 10 | import useMount from '../../hooks/useMount' 11 | import useNotice from '../../hooks/useNotice' 12 | import nativeApi from '../../utils/nativeApi' 13 | 14 | interface StrategyItem { 15 | name: string 16 | url: string 17 | highlight?: boolean 18 | alt?: string 19 | } 20 | 21 | export default function Strategy() { 22 | const navigate = useNavigate() 23 | const notice = useNotice() 24 | 25 | const { r: fetchRepo, d = [], loading } = useApi(nativeApi.getRepoData) 26 | 27 | useMount(() => fetchRepo('strategies.json')) 28 | 29 | function handleWindowOpen(link: string, external = false) { 30 | notice.success({ 31 | message: '正在打开页面...', 32 | duration: 1000 33 | }) 34 | 35 | if (external) { 36 | open(link) 37 | } else { 38 | nativeApi.openWindow(link) 39 | } 40 | } 41 | 42 | return ( 43 | <> 44 |
45 | {!loading ? ( 46 | <> 47 |
小窗攻略
48 |
49 | {d.map((e) => { 50 | const extra = e.highlight ? styles.highlight : '' 51 | const className = cn(styles.btn, extra) 52 | 53 | return ( 54 |
handleWindowOpen(e.url)} 58 | onContextMenu={() => handleWindowOpen(e.url, true)} 59 | > 60 | {e.name} 61 |
62 | ) 63 | })} 64 |
65 | 66 | ) : ( 67 | 68 | )} 69 | navigate('/')} 74 | /> 75 |
76 | {notice.holder} 77 | 78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /src/render/pages/role/constants.ts: -------------------------------------------------------------------------------- 1 | import Anemo from '../../../assets/anemo.png' 2 | import Cryo from '../../../assets/cryo.png' 3 | import Dendro from '../../../assets/dendro.png' 4 | import Electro from '../../../assets/electro.png' 5 | import Geo from '../../../assets/geo.png' 6 | import Hydro from '../../../assets/hydro.png' 7 | import Pyro from '../../../assets/pyro.png' 8 | import star1 from '../../../assets/star1.png' 9 | import star2 from '../../../assets/star2.png' 10 | import star3 from '../../../assets/star3.png' 11 | import star4 from '../../../assets/star4.png' 12 | import star5 from '../../../assets/star5.png' 13 | 14 | import type { TabType } from '.' 15 | 16 | export const ElementOptions = [ 17 | { 18 | value: 'all', 19 | label: '所有元素' 20 | }, 21 | { 22 | value: 'Pyro', 23 | label: '火元素' 24 | }, 25 | { 26 | value: 'Electro', 27 | label: '雷元素' 28 | }, 29 | { 30 | value: 'Geo', 31 | label: '岩元素' 32 | }, 33 | { 34 | value: 'Cryo', 35 | label: '冰元素' 36 | }, 37 | { 38 | value: 'Anemo', 39 | label: '风元素' 40 | }, 41 | { 42 | value: 'Hydro', 43 | label: '水元素' 44 | }, 45 | { 46 | value: 'Dendro', 47 | label: '草元素' 48 | } 49 | ] 50 | 51 | export const WeaponOptions = [ 52 | { 53 | value: 0, 54 | label: '所有武器' 55 | }, 56 | { 57 | value: 1, 58 | label: '单手剑' 59 | }, 60 | { 61 | value: 11, 62 | label: '双手剑' 63 | }, 64 | { 65 | value: 12, 66 | label: '弓' 67 | }, 68 | { 69 | value: 13, 70 | label: '长柄武器' 71 | }, 72 | { 73 | value: 10, 74 | label: '法器' 75 | } 76 | ] 77 | 78 | export const RarityOptions = [ 79 | { 80 | value: 0, 81 | label: '所有星级' 82 | }, 83 | { 84 | value: 5, 85 | label: '5星角色' 86 | }, 87 | { 88 | value: 4, 89 | label: '4星角色' 90 | } 91 | ] 92 | 93 | export const tabs: TabType[] = ['weapon', 'reliquary', 'constellation', 'profile'] 94 | 95 | export const TabMap: Record = { 96 | weapon: '武器', 97 | reliquary: '圣遗物', 98 | constellation: '命座', 99 | profile: '简介' 100 | } 101 | 102 | export const StarImgs: string[] = [star1, star2, star3, star4, star5] 103 | 104 | export const ElementImgs: Record = { 105 | Pyro, 106 | Hydro, 107 | Anemo, 108 | Electro, 109 | Geo, 110 | Cryo, 111 | Dendro 112 | } 113 | -------------------------------------------------------------------------------- /src/render/pages/portal/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { TiArrowBack } from 'react-icons/ti' 3 | import { useNavigate } from 'react-router-dom' 4 | 5 | import styles from './index.less' 6 | import CircleButton from '../../components/CircleButton' 7 | import Loading from '../../components/Loading' 8 | import useApi from '../../hooks/useApi' 9 | import useMount from '../../hooks/useMount' 10 | import useNotice from '../../hooks/useNotice' 11 | import nativeApi from '../../utils/nativeApi' 12 | 13 | interface PortalItem { 14 | name: string 15 | description: string 16 | url: string 17 | icon: string 18 | highlight: false 19 | browser: false 20 | } 21 | 22 | export default function Portal() { 23 | const navigate = useNavigate() 24 | const notice = useNotice() 25 | const { r: fetchRepo, data, loading } = useApi(nativeApi.getRepoData) 26 | 27 | useMount(() => fetchRepo('portals.json')) 28 | 29 | function handleClick(link: PortalItem, openInDefaultBrowser = false) { 30 | if (link?.browser || openInDefaultBrowser) { 31 | window.open(link.url) 32 | } else { 33 | notice.success({ message: '正在打开页面...', duration: 1000 }) 34 | nativeApi.openWindow(link.url, { title: link.name }) 35 | } 36 | } 37 | 38 | return ( 39 | <> 40 |
41 | {!loading ? ( 42 | <> 43 |
传送门
44 |
45 | {data?.map((e) => ( 46 |
handleClick(e)} 50 | onContextMenu={() => handleClick(e, true)} 51 | > 52 |
53 | 54 |
{e.name}
55 |
56 |
{e.description}
57 |
58 | ))} 59 |
60 |
61 | ※ 小提示:在任意链接上右键,可以使用系统「默认浏览器」打开页面。 62 |
63 | 64 | ) : ( 65 | 66 | )} 67 | navigate('/')} 72 | /> 73 |
74 | {notice.holder} 75 | 76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /src/render/components/WeaponCard/index.less: -------------------------------------------------------------------------------- 1 | @import '../../utils/colors.less'; 2 | @import '../../utils/utils.less'; 3 | 4 | .wrapper { 5 | .flex-center-column(); 6 | .transition(); 7 | 8 | color: #564c4a; 9 | // height: 148px; 10 | width: 110px; 11 | 12 | &:hover { 13 | transform: scale(1.05); 14 | } 15 | 16 | & > div { 17 | .flex-center-column(); 18 | .shadow-deep(); 19 | 20 | border-radius: 6px; 21 | // height: 121px; 22 | margin-bottom: 3px; 23 | position: relative; 24 | width: 100px; 25 | 26 | // 武器图 27 | img:nth-child(1) { 28 | width: 100px; 29 | height: 100px; 30 | } 31 | 32 | // 星级 icon 33 | img:nth-child(2) { 34 | bottom: 16px; 35 | position: absolute; 36 | height: 24px; 37 | } 38 | 39 | .corner-number { 40 | .flex-center(); 41 | 42 | background-color: rgba(64, 64, 64, 0.6); 43 | position: absolute; 44 | color: @color-white; 45 | font-size: 12px; 46 | height: 22px; 47 | width: 22px; 48 | } 49 | 50 | // 武器等级 51 | & > div:nth-child(3) { 52 | .corner-number(); 53 | 54 | border-top-left-radius: 6px; 55 | border-bottom-right-radius: 4px; 56 | left: 0px; 57 | top: 0px; 58 | } 59 | 60 | // 精炼等级 61 | & > div:nth-child(4) { 62 | .corner-number(); 63 | 64 | border-top-right-radius: 6px; 65 | border-bottom-left-radius: 4px; 66 | right: 0px; 67 | top: 0px; 68 | } 69 | 70 | & > span { 71 | font-size: 14px; 72 | line-height: 20px; 73 | text-shadow: none; 74 | } 75 | } 76 | } 77 | 78 | .star-1 { 79 | .bg-image-cover-no-repeat(); 80 | 81 | background-image: url('../../../assets/item-bg-1.png'); 82 | } 83 | 84 | .star-2 { 85 | .bg-image-cover-no-repeat(); 86 | 87 | background-image: url('../../../assets/item-bg-2.png'); 88 | } 89 | 90 | .star-3 { 91 | .bg-image-cover-no-repeat(); 92 | 93 | background-image: url('../../../assets/item-bg-3.png'); 94 | } 95 | 96 | .star-4 { 97 | .bg-image-cover-no-repeat(); 98 | 99 | background-image: url('../../../assets/item-bg-4.png'); 100 | } 101 | 102 | .star-5 { 103 | .bg-image-cover-no-repeat(); 104 | 105 | background-image: url('../../../assets/item-bg-5.png'); 106 | } 107 | 108 | .star-6 { 109 | .bg-image-cover-no-repeat(); 110 | 111 | background-image: url('../../../assets/item-bg-6.png'); 112 | } 113 | -------------------------------------------------------------------------------- /src/render/components/RoleCard/index.less: -------------------------------------------------------------------------------- 1 | @import '../../utils/colors.less'; 2 | @import '../../utils/utils.less'; 3 | 4 | .wrapper { 5 | .flex-center-column(); 6 | .transition(); 7 | 8 | color: #564c4a; 9 | // height: 148px; 10 | width: 110px; 11 | 12 | .border { 13 | border: 1px solid #cfcfcf; 14 | } 15 | 16 | &:hover { 17 | transform: scale(1.05); 18 | } 19 | 20 | & > div { 21 | .flex-center-column(); 22 | .shadow-deep(); 23 | 24 | border-radius: 6px; 25 | // height: 121px; 26 | margin-bottom: 3px; 27 | position: relative; 28 | width: 100px; 29 | 30 | // 角色头图 31 | img:nth-child(1) { 32 | width: 100px; 33 | height: 100px; 34 | } 35 | 36 | // 星级 icon 37 | img:nth-child(2) { 38 | bottom: 16px; 39 | position: absolute; 40 | height: 24px; 41 | } 42 | 43 | // 元素 icon 44 | img:nth-child(3) { 45 | left: 2px; 46 | position: absolute; 47 | top: 2px; 48 | width: 20px; 49 | } 50 | 51 | .corner-number { 52 | .flex-center(); 53 | 54 | background-color: rgba(64, 64, 64, 0.6); 55 | position: absolute; 56 | color: @color-white; 57 | font-size: 12px; 58 | height: 22px; 59 | width: 22px; 60 | } 61 | 62 | // 命座数 63 | & > div { 64 | .corner-number(); 65 | 66 | border-top-right-radius: 6px; 67 | border-bottom-left-radius: 4px; 68 | right: 0px; 69 | top: 0px; 70 | } 71 | 72 | span { 73 | font-size: 14px; 74 | line-height: 22px; 75 | } 76 | } 77 | 78 | & > span { 79 | font-size: 16px; 80 | margin-bottom: 2px; 81 | } 82 | } 83 | 84 | .star-1 { 85 | .bg-image-cover-no-repeat(); 86 | 87 | background-image: url('../../../assets/item-bg-1.png'); 88 | } 89 | 90 | .star-2 { 91 | .bg-image-cover-no-repeat(); 92 | 93 | background-image: url('../../../assets/item-bg-2.png'); 94 | } 95 | 96 | .star-3 { 97 | .bg-image-cover-no-repeat(); 98 | 99 | background-image: url('../../../assets/item-bg-3.png'); 100 | } 101 | 102 | .star-4 { 103 | .bg-image-cover-no-repeat(); 104 | 105 | background-image: url('../../../assets/item-bg-4.png'); 106 | } 107 | 108 | .star-5 { 109 | .bg-image-cover-no-repeat(); 110 | 111 | background-image: url('../../../assets/item-bg-5.png'); 112 | } 113 | 114 | .star-6 { 115 | .bg-image-cover-no-repeat(); 116 | 117 | background-image: url('../../../assets/item-bg-6.png'); 118 | } 119 | -------------------------------------------------------------------------------- /src/render/pages/calendar/WeekMaterial/index.less: -------------------------------------------------------------------------------- 1 | @import '../../../utils/colors.less'; 2 | @import '../../../utils/utils.less'; 3 | 4 | .week-material { 5 | .flex-center-x-column-wrap(); 6 | 7 | height: 428px; 8 | overflow: hidden; 9 | padding: 0 24px 32px 24px; 10 | position: relative; 11 | width: calc(100vw - 48px); 12 | 13 | .top { 14 | .ani-show-top(); 15 | } 16 | 17 | img { 18 | .transition(); 19 | 20 | width: 48px; 21 | height: $width; 22 | border-radius: 4px; 23 | object-fit: cover; 24 | } 25 | 26 | .boss-item { 27 | .ani-show-right(); 28 | .transition(); 29 | .flex-center-x-column(); 30 | 31 | border: 2px solid @color-bg-second; 32 | border-radius: 8px; 33 | margin-top: 20px; 34 | padding-top: 10px; 35 | 36 | & > div:first-child { 37 | .flex-center(); 38 | 39 | width: 100%; 40 | padding-bottom: 10px; 41 | border-bottom: 2px solid @color-bg-second; 42 | 43 | span { 44 | margin-left: 10px; 45 | font-size: 22px; 46 | } 47 | } 48 | 49 | & > div:nth-child(2) { 50 | .flex-center-x(); 51 | 52 | flex: 1; 53 | 54 | .material-item { 55 | .flex-center-x-column-wrap(); 56 | 57 | align-content: center; 58 | width: 288px; 59 | padding: 12px 0 12px 0; 60 | 61 | &:first-child { 62 | border-right: 2px solid @color-bg-second; 63 | } 64 | 65 | &:last-child { 66 | border-left: 2px solid @color-bg-second; 67 | } 68 | 69 | & > div:first-child { 70 | .flex-center(); 71 | 72 | width: 100%; 73 | padding-bottom: 4px; 74 | margin-top: 4px; 75 | 76 | span { 77 | margin-left: 12px; 78 | font-size: 18px; 79 | } 80 | } 81 | 82 | & > div:nth-child(2) { 83 | .flex-wrap(); 84 | 85 | flex: 1; 86 | padding-left: 6px; 87 | 88 | .role { 89 | .flex-center-x-column(); 90 | 91 | img { 92 | width: 84px; 93 | height: $width; 94 | border-radius: 6px; 95 | margin: 4px; 96 | } 97 | } 98 | } 99 | } 100 | } 101 | } 102 | 103 | .tip { 104 | .ani-show-bottom(); 105 | 106 | bottom: 0; 107 | color: @color-second; 108 | font-size: 14px; 109 | left: 30px; 110 | position: absolute; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/render/hooks/useNotice.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | 3 | import Alert from '../components/Alert' 4 | 5 | import type { AlertProp } from '../components/Alert' 6 | import type { ReactElement } from 'react' 7 | 8 | export interface AlertOptions { 9 | autoHide?: boolean 10 | duration?: number 11 | message: string 12 | type?: AlertProp['type'] 13 | } 14 | 15 | export interface Notice { 16 | failed: (options: AlertOptions | string) => void 17 | info: (options: AlertOptions | string) => void 18 | show: (options: AlertOptions | string) => void 19 | success: (options: AlertOptions | string) => void 20 | warning: (options: AlertOptions | string) => void 21 | hide: () => void 22 | holder: ReactElement 23 | } 24 | 25 | const useNotice = (): Notice => { 26 | const [aMessage, setAMessage] = useState('') 27 | const [timer, setTimer] = useState() 28 | const [aType, setAType] = useState('info') 29 | const [visible, setVisible] = useState(false) 30 | 31 | const showAlert = (options: AlertOptions) => { 32 | const { duration = 3000, type = 'info', message, autoHide = true } = options 33 | 34 | setAType(type) 35 | setAMessage(message) 36 | setVisible(true) 37 | 38 | if (timer) { 39 | clearTimeout(timer) 40 | } 41 | 42 | if (!autoHide) { 43 | return 44 | } 45 | 46 | setTimer(setTimeout(() => setVisible(false), duration)) 47 | } 48 | 49 | return { 50 | show(options: AlertOptions) { 51 | showAlert(options) 52 | }, 53 | hide() { 54 | setVisible(false) 55 | }, 56 | info(options: AlertOptions | string) { 57 | if (typeof options === 'string') { 58 | options = { message: options } 59 | } 60 | showAlert({ ...options, type: 'info' }) 61 | }, 62 | warning(options: AlertOptions | string) { 63 | if (typeof options === 'string') { 64 | options = { message: options } 65 | } 66 | showAlert({ ...options, type: 'warning' }) 67 | }, 68 | success(options: AlertOptions | string) { 69 | if (typeof options === 'string') { 70 | options = { message: options } 71 | } 72 | showAlert({ ...options, type: 'success' }) 73 | }, 74 | failed(options: AlertOptions | string) { 75 | if (typeof options === 'string') { 76 | options = { message: options } 77 | } 78 | showAlert({ ...options, type: 'failed' }) 79 | }, 80 | holder: 81 | } 82 | } 83 | 84 | export default useNotice 85 | -------------------------------------------------------------------------------- /src/render/pages/gacha/Statistics/index.less: -------------------------------------------------------------------------------- 1 | @import '../../../utils/colors.less'; 2 | @import '../../../utils/utils.less'; 3 | 4 | .content { 5 | .flex-column(); 6 | 7 | height: 428px; 8 | // 此处就不需要 hidden 了,防止 toolip 被遮挡 9 | // overflow: hidden; 10 | width: 920px; 11 | 12 | .time-range-container { 13 | width: 570px; 14 | display: flex; 15 | justify-content: flex-end; 16 | } 17 | 18 | .table-name { 19 | font-size: 18px; 20 | margin-bottom: 12px; 21 | margin-left: 42px; 22 | padding-left: 6px; 23 | border-left: 4px solid @color-accent; 24 | } 25 | } 26 | 27 | .row { 28 | .flex(); 29 | 30 | width: 920px; 31 | 32 | &:first-child { 33 | height: 190px; 34 | 35 | & > div { 36 | .ani-show-top(); 37 | } 38 | } 39 | 40 | &:nth-child(2) { 41 | .ani-show-bottom(); 42 | 43 | justify-content: space-between; 44 | } 45 | } 46 | 47 | .filter { 48 | z-index: 10; 49 | 50 | .filter-title { 51 | font-size: 18px; 52 | margin-bottom: 12px; 53 | padding-left: 6px; 54 | border-left: 4px solid @color-accent; 55 | } 56 | 57 | .pieTitle { 58 | font-size: 18px; 59 | margin-top: 20px; 60 | padding-left: 6px; 61 | border-left: 4px solid @color-accent; 62 | } 63 | 64 | .tip { 65 | color: @color-second; 66 | font-size: 14px; 67 | margin-top: 12px; 68 | } 69 | 70 | .filter-zone { 71 | .flex-column(); 72 | 73 | .filter-btns { 74 | display: flex; 75 | 76 | .btn { 77 | .transition(); 78 | 79 | background-color: @color-bg; 80 | border: 1px solid @color-second; 81 | border-radius: 4px; 82 | color: @color-second; 83 | font-size: 14px; 84 | height: 18px; 85 | margin: 3px; 86 | padding: 1px 6px; 87 | text-align: center; 88 | } 89 | 90 | .active { 91 | color: @color-white; 92 | } 93 | 94 | .btn-active { 95 | .active(); 96 | 97 | background-color: @color-star-5; 98 | border-color: @color-star-5; 99 | } 100 | 101 | .select { 102 | .btn(); 103 | 104 | margin-right: 14px; 105 | position: relative; 106 | 107 | &::before { 108 | color: @color-second; 109 | content: '|'; 110 | left: 46px; 111 | position: absolute; 112 | } 113 | } 114 | 115 | .select-all { 116 | .active(); 117 | 118 | background-color: @color-primary; 119 | border-color: @color-primary; 120 | } 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/services/getOwnedRoleList.ts: -------------------------------------------------------------------------------- 1 | import { API_TAKUMI_RECORD, LINK_BBS_REFERER } from '../constants' 2 | import { getCurrentUser } from '../main/IPC/getCurrentUser' 3 | import { getDS } from '../utils/getDS' 4 | import { getServerByUid } from '../utils/getServerByUid' 5 | import { request } from '../utils/request' 6 | 7 | import type { BaseRes } from '../typings' 8 | 9 | // 圣遗物词缀 10 | export interface Affixes { 11 | activation_number: number 12 | effect: string 13 | } 14 | 15 | // 圣遗物属性 16 | export interface Set { 17 | id: number 18 | name: string 19 | affixes: Affixes[] 20 | } 21 | 22 | // 衣装 23 | export interface Costume { 24 | id: number 25 | name: string 26 | icon: string 27 | } 28 | 29 | // 命座 30 | export interface Constellation { 31 | id: number 32 | name: string 33 | icon: string 34 | effect: string 35 | is_actived: boolean 36 | pos: number 37 | } 38 | 39 | // 圣遗物 40 | export interface Reliquarie { 41 | id: number 42 | name: string 43 | icon: string 44 | pos: number 45 | rarity: number 46 | level: number 47 | set: Set 48 | pos_name: string 49 | } 50 | 51 | // 武器 52 | export interface Weapon { 53 | id: number 54 | name: string 55 | icon: string 56 | type: number 57 | rarity: number 58 | level: number 59 | promote_level: number 60 | type_name: string 61 | desc: string 62 | affix_level: number 63 | } 64 | 65 | export interface Role { 66 | id: number 67 | image: string 68 | icon: string 69 | name: string 70 | element: string 71 | fetter: number 72 | level: number 73 | rarity: number 74 | weapon: Weapon 75 | reliquaries: Reliquarie[] 76 | constellations: Constellation[] 77 | actived_constellation_num: number 78 | costumes: Costume[] 79 | } 80 | 81 | export interface RoleData { 82 | avatars: Role[] 83 | } 84 | 85 | export async function getOwnedRoleList(uid?: string) { 86 | const currentUser = getCurrentUser() 87 | 88 | if (!currentUser) { 89 | return null 90 | } 91 | 92 | const targetUid = uid || currentUser.uid 93 | 94 | const { cookie } = currentUser 95 | const url = `${API_TAKUMI_RECORD}/game_record/app/genshin/api/character` 96 | const postData = { role_id: targetUid, server: getServerByUid(targetUid) } 97 | 98 | const headers = { 99 | referer: LINK_BBS_REFERER, 100 | DS: getDS('', JSON.stringify(postData)), 101 | cookie 102 | } 103 | 104 | const { status, data } = await request.post>(url, postData, { headers }) 105 | 106 | if (status !== 200 || data?.retcode !== 0) { 107 | console.log('getOwnedRoleList: ', data) 108 | } 109 | 110 | return data 111 | } 112 | -------------------------------------------------------------------------------- /src/render/pages/gacha/Statistics/components/ItemPie.tsx: -------------------------------------------------------------------------------- 1 | import { ResponsivePie } from '@nivo/pie' 2 | import React from 'react' 3 | 4 | import { ChartTheme } from '../../../../../constants' 5 | import { Colors } from '../../utils/getPieData' 6 | 7 | import type { CommonPieProps, MouseEventHandler } from '@nivo/pie' 8 | 9 | type ItemPieProp = { 10 | width: React.CSSProperties['width'] 11 | height: React.CSSProperties['height'] 12 | style?: React.CSSProperties 13 | onClick?: MouseEventHandler<{ id: string | number; value: number }, SVGPathElement> 14 | className?: string 15 | data: { id: string | number; value: number }[] 16 | } 17 | 18 | const defs = [ 19 | { 20 | id: 'lines', 21 | type: 'patternLines', 22 | background: 'inherit', 23 | color: 'rgba(255, 255, 255, 0.3)', 24 | rotation: -45, 25 | lineWidth: 6, 26 | spacing: 10 27 | } 28 | ] 29 | 30 | const fill = [ 31 | { match: { id: '武器' }, id: 'lines' }, 32 | { match: { id: '角色' }, id: 'lines' } 33 | ] 34 | 35 | const legends: CommonPieProps['legends'] = [ 36 | { 37 | anchor: 'bottom', 38 | direction: 'row', 39 | translateY: 40, 40 | translateX: 0, 41 | itemWidth: 54, 42 | itemHeight: 20, 43 | symbolSize: 16, 44 | symbolShape: 'circle', 45 | effects: [ 46 | { 47 | on: 'hover', 48 | style: { 49 | itemTextColor: '#ffa564' 50 | } 51 | } 52 | ] 53 | } 54 | ] 55 | 56 | export default function ItemPie(props: ItemPieProp) { 57 | const { data, style, width, height, onClick, className = '' } = props 58 | 59 | return ( 60 |
70 | 97 |
98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /src/render/pages/gacha/utils/getPieData.ts: -------------------------------------------------------------------------------- 1 | import { GachaTypeMap, ItemTypeMap } from './filterGachaList' 2 | 3 | import type { GachaData } from '../../../../typings' 4 | 5 | export const Colors: Record = { 6 | blue: '#73abcd', 7 | purple: '#9779c2', 8 | golden: '#ffa564', 9 | red: '#da4e55' 10 | } 11 | 12 | const s5 = { 13 | id: '5星', 14 | label: '5星', 15 | value: 0, 16 | color: Colors.golden 17 | } 18 | 19 | const s4 = { 20 | id: '4星', 21 | label: '4星', 22 | value: 0, 23 | color: Colors.purple 24 | } 25 | 26 | const s3 = { 27 | id: '3星', 28 | label: '3星', 29 | value: 0, 30 | color: Colors.blue 31 | } 32 | 33 | const i1 = { 34 | id: '角色', 35 | label: '角色', 36 | value: 0, 37 | color: Colors.golden 38 | } 39 | 40 | const i2 = { 41 | id: '武器', 42 | label: '武器', 43 | value: 0, 44 | color: Colors.purple 45 | } 46 | 47 | const t1 = { 48 | id: '角色池', 49 | label: '角色池', 50 | value: 0, 51 | color: Colors.golden 52 | } 53 | 54 | const t2 = { 55 | id: '武器池', 56 | label: '武器池', 57 | value: 0, 58 | color: Colors.purple 59 | } 60 | 61 | const t3 = { 62 | id: '常驻池', 63 | label: '常驻池', 64 | value: 0, 65 | color: Colors.blue 66 | } 67 | 68 | const t4 = { 69 | id: '新手池', 70 | label: '新手池', 71 | value: 0, 72 | color: Colors.purple 73 | } 74 | 75 | export default function getPieData(type: 'star' | 'item' | 'type', list: GachaData['list']) { 76 | switch (type) { 77 | case 'star': { 78 | ;[s5, s4, s3].forEach((e) => { 79 | e.value = 0 80 | }) 81 | for (const item of list) { 82 | if (item.rank_type === '5') s5.value++ 83 | if (item.rank_type === '4') s4.value++ 84 | if (item.rank_type === '3') s3.value++ 85 | } 86 | return [s3, s4, s5] 87 | } 88 | case 'item': { 89 | ;[i1, i2].forEach((e) => { 90 | e.value = 0 91 | }) 92 | for (const item of list) { 93 | if (item.item_type === ItemTypeMap.role) i1.value++ 94 | if (item.item_type === ItemTypeMap.weapon) i2.value++ 95 | } 96 | return [i1, i2] 97 | } 98 | case 'type': { 99 | ;[t1, t2, t3, t4].forEach((e) => { 100 | e.value = 0 101 | }) 102 | for (const { uigf_gacha_type: _type } of list) { 103 | if (_type === GachaTypeMap.activity) { 104 | t1.value++ 105 | } 106 | 107 | if (_type === GachaTypeMap.weapon) { 108 | t2.value++ 109 | } 110 | 111 | if (_type === GachaTypeMap.normal) { 112 | t3.value++ 113 | } 114 | 115 | if (_type === GachaTypeMap.newer) { 116 | t4.value++ 117 | } 118 | } 119 | return [t1, t2, t3, t4] 120 | } 121 | 122 | default: { 123 | return [] 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/render/pages/gacha/Statistics/components/StarPie.tsx: -------------------------------------------------------------------------------- 1 | import { ResponsivePie } from '@nivo/pie' 2 | import React from 'react' 3 | 4 | import { ChartTheme } from '../../../../../constants' 5 | import { Colors } from '../../utils/getPieData' 6 | 7 | import type { CommonPieProps, MouseEventHandler } from '@nivo/pie' 8 | 9 | type StarPieProp = { 10 | width: React.CSSProperties['width'] 11 | height: React.CSSProperties['height'] 12 | style?: React.CSSProperties 13 | onClick?: MouseEventHandler<{ id: string | number; value: number }, SVGPathElement> 14 | className?: string 15 | data: { id: string | number; value: number }[] 16 | } 17 | 18 | const defs = [ 19 | { 20 | id: 'lines', 21 | type: 'patternLines', 22 | background: 'inherit', 23 | color: 'rgba(255, 255, 255, 0.3)', 24 | rotation: -45, 25 | lineWidth: 6, 26 | spacing: 10 27 | } 28 | ] 29 | 30 | const fill = [ 31 | { match: { id: '3星' }, id: 'lines' }, 32 | { match: { id: '4星' }, id: 'lines' }, 33 | { match: { id: '5星' }, id: 'lines' } 34 | ] 35 | 36 | const legends: CommonPieProps['legends'] = [ 37 | { 38 | anchor: 'bottom', 39 | direction: 'row', 40 | translateY: 40, 41 | translateX: 0, 42 | itemWidth: 48, 43 | itemHeight: 20, 44 | symbolSize: 16, 45 | symbolShape: 'circle', 46 | effects: [ 47 | { 48 | on: 'hover', 49 | style: { 50 | itemTextColor: '#ffa564' 51 | } 52 | } 53 | ] 54 | } 55 | ] 56 | 57 | export default function ItemPie(props: StarPieProp) { 58 | const { data, style, width, height, onClick, className = '' } = props 59 | return ( 60 |
70 | 97 |
98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /src/render/pages/role/utils.ts: -------------------------------------------------------------------------------- 1 | import { StarImgs } from './constants' 2 | 3 | import type { Reliquarie, Role as RoleInfo } from '../../../services/getOwnedRoleList' 4 | import type { PublicRole } from '../../../services/getPublicRoleList' 5 | 6 | type RenderRoleInfo = RoleInfo & PublicRole 7 | 8 | export interface ReliquaryEffect { 9 | name: string 10 | effects: { num: number; effect: string }[] 11 | } 12 | 13 | export function getStarImage(rarity: number) { 14 | return StarImgs[(rarity > 5 ? 5 : rarity) - 1] 15 | } 16 | 17 | // 将获取的个人角色信息和公开的角色的信息合并 18 | export function getFullRoleInfo(roles: RoleInfo[], publickRoles: PublicRole[]): RenderRoleInfo[] { 19 | const res = [] 20 | for (const role of roles) { 21 | if (role.name === '旅行者') { 22 | res.push({ 23 | ...role, 24 | name: role.image.includes('Girl') ? '旅行者·荧' : '旅行者·空', 25 | introduction: '从世界之外漂流而来的旅行者,被神带走血亲,自此踏上寻找七神之路', 26 | startTime: '2020-09-28 00:00:00', 27 | line: '', 28 | CVs: [ 29 | { 30 | name: role.image.includes('Girl') ? '宴宁' : '鹿喑', 31 | type: '中', 32 | vos: [] 33 | }, 34 | { 35 | name: role.image.includes('Girl') ? '悠木碧' : '堀江瞬', 36 | type: '日', 37 | vos: [] 38 | } 39 | ] 40 | }) 41 | } else { 42 | for (const publickRole of publickRoles) { 43 | if (role.name === publickRole.name) { 44 | res.push({ ...role, ...publickRole }) 45 | } 46 | } 47 | } 48 | } 49 | return res 50 | } 51 | 52 | // 圣遗物套装效果转换算法 53 | export function getReliquaryEffects(reliquaries: Reliquarie[]): ReliquaryEffect[] { 54 | const effects: ReliquaryEffect[] = [] 55 | // 对每一个圣遗物进行遍历 56 | for (const e of reliquaries) { 57 | const isExist = effects.map((f) => f.name).includes(e.set.name) 58 | // 如果该系列已经处理过,则跳过 59 | if (!isExist) { 60 | // 没处理则往下处理,先获取圣遗物套装名称 61 | const effectName = e.set.name 62 | // 获取需要触发套装效果的数目列表,一般是 2、4 63 | const effectNums = e.set.affixes.map((f) => f.activation_number) 64 | // 获取已装配的该系列套装的数目 65 | const setNum = reliquaries.filter((f) => f.set.name === effectName).length 66 | // 声明一个套装系列效果的列表 { num: number, effect: string } 67 | const calculatedEffects = [] 68 | // 遍历触发套装效果的数目列表,依次判断是否达到数目要求 69 | for (const num of effectNums) { 70 | // 达到数目要求则视为有效效果,加入到套装系列效果的列表 71 | if (setNum >= num) { 72 | const affiex = e.set.affixes.find((f) => f.activation_number === num) 73 | calculatedEffects.push({ num: affiex.activation_number, effect: affiex.effect }) 74 | } 75 | } 76 | // 如果该系列存在有效的效果,则将该系列的圣遗物效果加入到总效果列表中 77 | if (calculatedEffects.length > 0) { 78 | effects.push({ name: e.set.name, effects: calculatedEffects }) 79 | } 80 | } 81 | } 82 | // 返回套装总效果 83 | return effects 84 | } 85 | -------------------------------------------------------------------------------- /src/render/pages/note/Pie.tsx: -------------------------------------------------------------------------------- 1 | import { ResponsivePie } from '@nivo/pie' 2 | import React from 'react' 3 | 4 | import { ChartTheme } from '../../../constants' 5 | import { Colors } from '../gacha/utils/getPieData' 6 | 7 | import type { CommonPieProps } from '@nivo/pie' 8 | 9 | type NotePieProp = { 10 | width: React.CSSProperties['width'] 11 | height: React.CSSProperties['height'] 12 | style?: React.CSSProperties 13 | className?: string 14 | data: { id: string | number; value: number }[] 15 | } 16 | 17 | const defs = [ 18 | { 19 | id: 'lines', 20 | type: 'patternLines', 21 | background: 'inherit', 22 | color: 'rgba(255, 255, 255, 0.3)', 23 | rotation: -45, 24 | lineWidth: 6, 25 | spacing: 10 26 | } 27 | ] 28 | 29 | const fill = [ 30 | { match: { id: '冒险奖励' }, id: 'lines' }, 31 | { match: { id: '任务奖励' }, id: 'lines' }, 32 | { match: { id: '每日活跃' }, id: 'lines' }, 33 | { match: { id: '深境螺旋' }, id: 'lines' }, 34 | { match: { id: '邮件奖励' }, id: 'lines' }, 35 | { match: { id: '活动奖励' }, id: 'lines' }, 36 | { match: { id: '其他' }, id: 'lines' } 37 | ] 38 | 39 | const legends: CommonPieProps['legends'] = [ 40 | { 41 | anchor: 'right', 42 | direction: 'column', 43 | translateY: 0, 44 | translateX: 54, 45 | itemWidth: 60, 46 | itemHeight: 24, 47 | symbolSize: 18, 48 | symbolShape: 'circle', 49 | effects: [ 50 | { 51 | on: 'hover', 52 | style: { 53 | itemTextColor: '#ffa564' 54 | } 55 | } 56 | ] 57 | } 58 | ] 59 | 60 | export default function GachaPie(props: NotePieProp) { 61 | const { data, style, width, height, className = '' } = props 62 | 63 | return ( 64 |
74 | 100 |
101 | ) 102 | } 103 | -------------------------------------------------------------------------------- /src/render/pages/gacha/Statistics/components/TypePie.tsx: -------------------------------------------------------------------------------- 1 | import { ResponsivePie } from '@nivo/pie' 2 | import React from 'react' 3 | 4 | import { ChartTheme } from '../../../../../constants' 5 | import { Colors } from '../../utils/getPieData' 6 | 7 | import type { CommonPieProps, MouseEventHandler } from '@nivo/pie' 8 | 9 | type TypePieProp = { 10 | width: React.CSSProperties['width'] 11 | height: React.CSSProperties['height'] 12 | style?: React.CSSProperties 13 | onClick?: MouseEventHandler<{ id: string | number; value: number }, SVGPathElement> 14 | className?: string 15 | data: { id: string | number; value: number }[] 16 | } 17 | 18 | const defs = [ 19 | { 20 | id: 'lines', 21 | type: 'patternLines', 22 | background: 'inherit', 23 | color: 'rgba(255, 255, 255, 0.3)', 24 | rotation: -45, 25 | lineWidth: 6, 26 | spacing: 10 27 | } 28 | ] 29 | 30 | const fill = [ 31 | { match: { id: '角色池' }, id: 'lines' }, 32 | { match: { id: '武器池' }, id: 'lines' }, 33 | { match: { id: '常驻池' }, id: 'lines' }, 34 | { match: { id: '新手池' }, id: 'lines' } 35 | ] 36 | 37 | const legends: CommonPieProps['legends'] = [ 38 | { 39 | anchor: 'bottom', 40 | direction: 'row', 41 | translateY: 40, 42 | translateX: 0, 43 | itemWidth: 64, 44 | itemHeight: 20, 45 | symbolSize: 16, 46 | symbolShape: 'circle', 47 | effects: [ 48 | { 49 | on: 'hover', 50 | style: { 51 | itemTextColor: '#ffa564' 52 | } 53 | } 54 | ] 55 | } 56 | ] 57 | 58 | export default function TypePie(props: TypePieProp) { 59 | const { data, style, width, height, onClick, className = '' } = props 60 | 61 | return ( 62 |
72 | 99 |
100 | ) 101 | } 102 | -------------------------------------------------------------------------------- /src/main/IPC/handleGachaFile.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import { app, dialog } from 'electron' 3 | import fs from 'fs-extra' 4 | import json from 'json-bigint' 5 | 6 | import { getLocalGachaData } from './getLocalGachaData' 7 | import { mainWin } from '..' 8 | import { AppName } from '../../constants' 9 | import { updateLocalGachaData } from '../../utils/updateLocalGachaData' 10 | 11 | import type { GachaData } from '../../typings' 12 | 13 | /** 通过 UID 导出本地的 JSON 祈愿数据 */ 14 | export async function exportGacha(uid: string) { 15 | const now = dayjs().format('YYYYMMDDHHmmss') 16 | 17 | // 保存文件对话框 18 | const { filePath } = await dialog.showSaveDialog(mainWin, { 19 | title: `导出 UID ${uid} 的祈愿记录数据文件`, 20 | defaultPath: `${app.getPath('desktop')}/${uid}-${now}.json`, 21 | buttonLabel: '导出' 22 | }) 23 | 24 | if (!filePath) { 25 | return { 26 | code: -1, 27 | data: null, 28 | message: '已取消导出操作' 29 | } 30 | } 31 | 32 | // 找到对应的 uid 祈愿数据文件 33 | const data = getLocalGachaData().find((e) => e.info.uid === uid) 34 | 35 | if (!data) { 36 | return { 37 | code: -1, 38 | data: null, 39 | message: '目标 uid 不存在' 40 | } 41 | } 42 | 43 | data.info.lang = 'zh-cn' 44 | data.info.uigf_version = 'v2.2' 45 | data.info.export_app = AppName.en 46 | data.info.export_app_version = app.getVersion() 47 | data.info.export_time = dayjs().format('YYYY-MM-DD HH:mm:ss') 48 | data.info.export_timestamp = Date.now() + '' 49 | 50 | // 尝试写出文件 51 | fs.writeJsonSync(filePath, data) 52 | 53 | return { code: 0, data, message: `已成功导出 UID ${uid} 的祈愿数据!` } 54 | } 55 | 56 | /** 导入 JSON 祈愿数据 */ 57 | export async function importGacha() { 58 | // 打开对话框, 选择 JSON 文件 59 | const { filePaths } = await dialog.showOpenDialog(mainWin, { 60 | title: '导入祈愿记录数据文件', 61 | defaultPath: app.getPath('desktop'), 62 | buttonLabel: '导入', 63 | filters: [ 64 | { 65 | name: 'JSON 文件', 66 | extensions: ['json'] 67 | } 68 | ], 69 | properties: ['showHiddenFiles', 'openFile'] 70 | }) 71 | 72 | if (!filePaths.length) { 73 | return { 74 | code: -1, 75 | data: null, 76 | message: '已取消导入操作' 77 | } 78 | } 79 | 80 | // 处理 js 整数溢出问题 81 | const raw = fs.readFileSync(filePaths[0], { encoding: 'utf8' }) 82 | const config = json.parse(raw) as GachaData 83 | 84 | if (config.list.length && typeof config.list[0]?.id === 'object') { 85 | for (let i = 0; i < config.list.length; i++) { 86 | config.list[i].id = String(config.list[i].id) 87 | } 88 | } 89 | 90 | if (!config.info || !config.list) { 91 | return { 92 | code: 1, 93 | data: null, 94 | message: '格式解析失败,请导入符合要求的 JSON 数据' 95 | } 96 | } 97 | 98 | updateLocalGachaData(config) 99 | 100 | return { 101 | code: 0, 102 | data: config, 103 | message: `已成功导入 UID ${config.info.uid} 的祈愿数据!` 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gs-helper", 3 | "productName": "GenshinHelper", 4 | "packageManager": "pnpm@7.29.0", 5 | "private": true, 6 | "version": "1.2.4", 7 | "description": "PC 端小工具,支持原神签到、祈愿分析、查看便签状态和游戏详细数据等。基于 Electron 和 React。", 8 | "main": ".webpack/main", 9 | "prettier": "@vikiboss/prettier-config", 10 | "scripts": { 11 | "dev": "electron-forge start", 12 | "dev:watch": "electron-forge start --inspect-electron", 13 | "package": "electron-forge package", 14 | "make": "electron-forge make", 15 | "lint": "pnpm run prettier && pnpm run eslint", 16 | "lint:fix": "pnpm run prettier:fix && pnpm run eslint:fix", 17 | "prettier": "prettier ./**/*.{less,html,md,json}", 18 | "prettier:fix": "prettier --write ./**/*.{less,html,md,json}", 19 | "eslint": "eslint --ext .js,.jsx,.ts,.tsx .", 20 | "eslint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix" 21 | }, 22 | "repository": "vikiboss/gs-helper", 23 | "homepage": "https://github.com/vikiboss/gs-helper#readme", 24 | "author": "Viki (https://github.com/vikiboss)", 25 | "license": "GPL-3.0", 26 | "config": { 27 | "forge": "./forge.config.js" 28 | }, 29 | "devDependencies": { 30 | "@electron-forge/cli": "^6.2.1", 31 | "@electron-forge/maker-zip": "^6.2.1", 32 | "@electron-forge/plugin-webpack": "^6.2.1", 33 | "@types/dom-to-image": "^2.6.4", 34 | "@types/fs-extra": "^9.0.13", 35 | "@types/json-bigint": "^1.0.1", 36 | "@types/node": "^18.16.18", 37 | "@types/react": "^18.2.14", 38 | "@types/react-dom": "^18.2.6", 39 | "@types/uuid": "^8.3.4", 40 | "@vercel/webpack-asset-relocator-loader": "1.7.0", 41 | "@vikiboss/prettier-config": "^0.2.2", 42 | "conf": "^10.2.0", 43 | "css-loader": "^6.8.1", 44 | "electron": "^25.2.0", 45 | "eslint": "^8.43.0", 46 | "eslint-config-prettier": "^8.8.0", 47 | "eslint-config-viki-react": "^0.1.1", 48 | "eslint-plugin-n": "^15.7.0", 49 | "eslint-plugin-prettier": "^4.2.1", 50 | "eslint-plugin-promise": "^6.1.1", 51 | "fork-ts-checker-webpack-plugin": "^6.5.3", 52 | "json-schema-typed": "^8.0.1", 53 | "less": "^4.1.3", 54 | "less-loader": "^10.2.0", 55 | "node-loader": "^2.0.0", 56 | "prettier": "^2.8.8", 57 | "prop-types": "^15.8.1", 58 | "react-router": "^6.14.0", 59 | "style-loader": "^3.3.3", 60 | "ts-loader": "^9.4.3", 61 | "typescript": "^4.9.5", 62 | "webpack": "^5.88.0" 63 | }, 64 | "dependencies": { 65 | "@nivo/bar": "^0.80.0", 66 | "@nivo/calendar": "^0.80.0", 67 | "@nivo/core": "^0.80.0", 68 | "@nivo/pie": "^0.80.0", 69 | "@nivo/tooltip": "0.80.0", 70 | "axios": "^1.4.0", 71 | "classnames": "^2.3.2", 72 | "dayjs": "^1.11.8", 73 | "dom-to-image": "^2.6.0", 74 | "electron-store": "^8.1.0", 75 | "fs-extra": "^11.1.1", 76 | "json-bigint": "^1.0.0", 77 | "react": "^18.2.0", 78 | "react-dom": "^18.2.0", 79 | "react-icons": "^4.10.1", 80 | "react-router-dom": "^6.14.0", 81 | "uuid": "^8.3.2" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/render/pages/gacha/index.less: -------------------------------------------------------------------------------- 1 | @import '../../utils/colors.less'; 2 | @import '../../utils/utils.less'; 3 | 4 | @input-bg-color: @color-white; 5 | @input-border-color: #d6d6d6; 6 | @input-border-hover-color: @color-second; 7 | @input-text-color: @color-primary; 8 | 9 | .container { 10 | .flex-center-x-column(); 11 | 12 | flex: 1; 13 | position: relative; 14 | height: 100%; 15 | 16 | .top-zone { 17 | .flex-center-y(); 18 | 19 | height: 82px; 20 | justify-content: flex-start; 21 | padding-left: 40px; 22 | width: calc(100vw - 40px - 80px); 23 | 24 | input { 25 | .ani-show-right(); 26 | 27 | background-color: @input-bg-color; 28 | border: 2px solid @input-border-color; 29 | border-radius: 6px; 30 | color: @color-second; 31 | font-size: 18px; 32 | font-weight: bold; 33 | height: 26px; 34 | margin-right: 12px; 35 | outline: none; 36 | padding: 2px 6px; 37 | text-overflow: ellipsis; 38 | transition: all 0.6s cubic-bezier(0.36, 0.79, 0.68, 0.99); 39 | width: 160px; 40 | will-change: auto; 41 | z-index: 1; 42 | 43 | &:focus { 44 | flex: 1; 45 | 46 | // 通用兄弟选择器:同一层级后面所有含有 .title 类的元素都会被选中 47 | & ~ .right-zone { 48 | .transition-slow(); 49 | 50 | opacity: 0; 51 | } 52 | } 53 | 54 | &::placeholder { 55 | color: @input-border-color; 56 | transition: all 0.6s cubic-bezier(0.36, 0.79, 0.68, 0.99); 57 | } 58 | 59 | &:hover { 60 | border-color: @input-border-hover-color; 61 | color: @color-primary; 62 | 63 | &::placeholder { 64 | color: @input-border-hover-color; 65 | } 66 | } 67 | } 68 | 69 | .btn { 70 | .ani-show-right(); 71 | } 72 | 73 | .right-zone { 74 | .ani-show-right(); 75 | .flex-center-y(); 76 | 77 | // width : 420px; 78 | flex: 1; 79 | justify-content: flex-end; 80 | position: absolute; 81 | right: 40px; 82 | top: 24px; 83 | 84 | .select-btn { 85 | margin-left: 12px; 86 | } 87 | 88 | .icon { 89 | .transition(); 90 | .flex-center(); 91 | 92 | background-color: @color-bg-deep; 93 | border-radius: 6px; 94 | height: 28px; 95 | margin-left: 12px; 96 | padding: 2px; 97 | position: relative; 98 | width: 28px; 99 | 100 | &:hover { 101 | background-color: @color-accent; 102 | color: @color-white; 103 | } 104 | } 105 | } 106 | } 107 | 108 | .date-tip { 109 | .ani-show-right(); 110 | 111 | bottom: 16px; 112 | color: @color-second; 113 | // font-size: 12px; 114 | left: 20px; 115 | position: absolute; 116 | } 117 | 118 | .back-btn { 119 | .ani-show-right(); 120 | 121 | left: 20px; 122 | position: absolute; 123 | top: 20px; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/services/getCabinetRoleList.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentUser } from '../main/IPC/getCurrentUser' 2 | import { request } from '../utils/request' 3 | 4 | export interface ShowAvatarInfoList { 5 | avatarId: number 6 | level: number 7 | } 8 | 9 | export interface ProfilePicture { 10 | avatarId: number 11 | } 12 | 13 | export interface PropMap { 14 | type: number 15 | ival: string 16 | val?: string 17 | } 18 | 19 | export interface Reliquary { 20 | level: number 21 | mainPropId: number 22 | appendPropIdList: number[] 23 | } 24 | 25 | export interface ReliquaryMainstat { 26 | mainPropId: string 27 | statValue: number 28 | } 29 | 30 | export interface ReliquarySubstats { 31 | appendPropId: string 32 | statValue: number 33 | } 34 | 35 | export interface WeaponStats { 36 | appendPropId: string 37 | statValue: number 38 | } 39 | 40 | export interface Flat { 41 | nameTextMapHash: string 42 | setNameTextMapHash?: string 43 | rankLevel: number 44 | reliquaryMainstat?: ReliquaryMainstat 45 | reliquarySubstats?: ReliquarySubstats[] 46 | itemType: string 47 | icon: string 48 | equipType?: string 49 | weaponStats?: WeaponStats[] 50 | } 51 | 52 | export interface Weapon { 53 | level: number 54 | promoteLevel?: number 55 | affixMap: Record 56 | } 57 | 58 | export interface EquipList { 59 | itemId: number 60 | reliquary?: Reliquary 61 | flat: Flat 62 | weapon?: Weapon 63 | } 64 | 65 | export interface FetterInfo { 66 | expLevel: number 67 | } 68 | 69 | export interface AvatarInfoList { 70 | avatarId: number 71 | propMap: PropMap 72 | fightPropMap: Record 73 | skillDepotId: number 74 | inherentProudSkillList: number[] 75 | skillLevelMap: Record 76 | equipList: EquipList[] 77 | fetterInfo: FetterInfo 78 | talentIdList?: number[] 79 | proudSkillExtraLevelMap?: Record 80 | } 81 | 82 | export interface PlayerInfo { 83 | nickname: string 84 | level: number 85 | signature: string 86 | worldLevel: number 87 | nameCardId: number 88 | finishAchievementNum: number 89 | towerFloorIndex: number 90 | towerLevelIndex: number 91 | showAvatarInfoList: ShowAvatarInfoList[] 92 | showNameCardIdList: number[] 93 | profilePicture: ProfilePicture 94 | } 95 | 96 | export interface EnkaUserData { 97 | playerInfo: PlayerInfo 98 | avatarInfoList: AvatarInfoList[] 99 | ttl: number 100 | uid: string 101 | } 102 | 103 | const api = (uid: string) => `https://enka.network/u/${uid}/__data.json` 104 | 105 | /** 获取游戏内展示柜的角色详情,来自 enka.network */ 106 | export async function getCabinetRoleList(uid?: string) { 107 | const currentUser = getCurrentUser() 108 | 109 | if (!currentUser) { 110 | return null 111 | } 112 | 113 | const targetUid = uid ?? currentUser.uid 114 | 115 | const { status, data } = await request.get(api(targetUid)) 116 | 117 | if (status !== 200) { 118 | console.log('getCabinetRoleList: ', data) 119 | } 120 | 121 | return data 122 | } 123 | -------------------------------------------------------------------------------- /src/render/components/Button/index.less: -------------------------------------------------------------------------------- 1 | @import '../../utils/colors.less'; 2 | @import '../../utils/utils.less'; 3 | 4 | @size-small: 30px; 5 | @size-middle: 42px; 6 | @size-large: 64px; 7 | 8 | @btn-actiove-bg: #ffecca; 9 | @btn-active-border-color: #b9b6b3; 10 | @btn-bg-color: #4a5366; 11 | @btn-hover-border-color: #ffdca0; 12 | @btn-icon-active-bg-color: #978159; 13 | @btn-icon-bg-color: #313131; 14 | @btn-icon-blue: #38a2e4; 15 | @btn-icon-yellow: #eabb32; 16 | @btn-text-active-color: #a1927d; 17 | @btn-text-color: #ece5d8; 18 | 19 | .btn { 20 | .transition(); 21 | 22 | align-items: center; 23 | border: 1px solid transparent; 24 | box-shadow: 1px 1px 4px 4px rgba(220, 220, 220, 0.3); 25 | display: flex; 26 | overflow: hidden; 27 | position: relative; 28 | text-shadow: none; 29 | 30 | .confirm { 31 | color: @btn-icon-yellow; 32 | } 33 | 34 | .cancel { 35 | color: @btn-icon-blue; 36 | } 37 | 38 | .icon { 39 | .transition(); 40 | 41 | background-color: @btn-icon-bg-color; 42 | border-radius: 50%; 43 | position: absolute; 44 | } 45 | 46 | .text { 47 | flex: 1; 48 | text-align: center; 49 | } 50 | 51 | .with-icon { 52 | position: relative; 53 | } 54 | 55 | &:active { 56 | background-color: @btn-actiove-bg; 57 | border-color: @btn-active-border-color; 58 | 59 | .icon { 60 | background-color: @btn-icon-active-bg-color; 61 | } 62 | 63 | .text { 64 | color: @btn-text-active-color; 65 | } 66 | } 67 | 68 | &:hover { 69 | border-color: @btn-hover-border-color; 70 | } 71 | } 72 | 73 | .dark { 74 | color: @btn-text-color; 75 | background-color: @btn-bg-color; 76 | border-color: @color-second; 77 | } 78 | 79 | .light { 80 | color: @color-primary; 81 | background-color: @color-bg-accent; 82 | border-color: @color-white; 83 | } 84 | 85 | .small { 86 | border-radius: calc((@size-small + 6px) / 2); 87 | height: @size-small; 88 | padding: 0 @size-small * 0.8; 89 | 90 | .icon { 91 | left: @size-small * 0.17; 92 | padding: @size-small * 0.12; 93 | } 94 | 95 | .text { 96 | font-size: @size-small * 0.42; 97 | } 98 | 99 | .with-icon { 100 | left: @size-small * 0.18; 101 | } 102 | } 103 | 104 | .middle { 105 | border-radius: calc((@size-middle + 8px) / 2); 106 | height: @size-middle; 107 | padding: 0 @size-middle * 0.8; 108 | 109 | .icon { 110 | left: @size-middle * 0.17; 111 | padding: @size-middle * 0.12; 112 | } 113 | 114 | .text { 115 | font-size: @size-middle * 0.42; 116 | } 117 | 118 | .with-icon { 119 | left: @size-middle * 0.18; 120 | } 121 | } 122 | 123 | .large { 124 | border-radius: calc((@size-large + 10px) / 2); 125 | height: @size-large; 126 | padding: 0 @size-large * 0.8; 127 | 128 | .icon { 129 | left: @size-large * 0.17; 130 | padding: @size-large * 0.12; 131 | } 132 | 133 | .text { 134 | font-size: @size-large * 0.42; 135 | } 136 | 137 | .with-icon { 138 | left: @size-large * 0.18; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/render/pages/gacha/utils/getGachaStatistics.ts: -------------------------------------------------------------------------------- 1 | import getListByType from './getListByType' 2 | import { GachaMap } from '..' 3 | 4 | import type { GachaData, GachaType } from '../../../../typings' 5 | 6 | function getComment(count: number) { 7 | // 抽卡期望 62 抽,以此为分界线,上下展开, 分为九个区间 8 | // >=80, 79-76, 75-70, 69-65, 64-60, 59-53, 53-48, 47-18, <=17 9 | 10 | switch (true) { 11 | case count >= 80: 12 | return '超级无敌大非酋' 13 | case count >= 76: 14 | return '无敌大非酋' 15 | case count >= 70: 16 | return '大非酋' 17 | case count >= 65: 18 | return '非酋' 19 | case count >= 60: 20 | return '中规中矩' 21 | case count >= 53: 22 | return '欧皇' 23 | case count >= 48: 24 | return '大欧皇' 25 | case count >= 18: 26 | return '大欧皇' 27 | case count >= 1: 28 | return '超级无敌大欧皇' 29 | default: // <= 0 30 | return '欧皇正在酝酿' 31 | } 32 | } 33 | 34 | export default function getGachaStatistics(gacha: GachaData) { 35 | const map = Object.keys(GachaMap).filter((e) => e !== 'newer') as GachaType[] 36 | 37 | const res: { 38 | all: number 39 | prestige: number 40 | number: number 41 | comment: string 42 | times: number 43 | // 未出金的抽数 44 | unluckyDays?: number 45 | // 未出紫的抽数 46 | unluckyDays_4?: number 47 | name: string 48 | }[] = [] 49 | 50 | for (const type of map) { 51 | // 获取相应祈愿分类的所有数据 52 | const list = getListByType(gacha.list, type) 53 | // 存放所有 5 星的索引(1 开始) 54 | const i5 = [] 55 | 56 | for (const [i, e] of list.entries()) { 57 | if (e.rank_type === '5') i5.push(i + 1) 58 | } 59 | 60 | // 存放所有 4 星及以上的索引(1 开始) 61 | const i4 = [] 62 | 63 | for (const [i, e] of list.entries()) { 64 | if (Number(e.rank_type) === 4) i4.push(i + 1) 65 | } 66 | 67 | // 5 星平均出货次数 68 | const times = i5.length ? i5[i5.length - 1] / i5.length : 0 69 | // 累计未出 5 星的次数 70 | const unluckyDays = list.length - (i5.length ? i5[i5.length - 1] : 0) 71 | // 累计未出 4 星的次数 72 | const unluckyDays4 = list.length - (i4.length ? i4[i4.length - 1] : 0) 73 | 74 | res.push({ 75 | all: list.length, 76 | number: i5.length, 77 | comment: getComment(times), 78 | times: Math.round(times), 79 | prestige: Math.round(times * 160), 80 | unluckyDays, 81 | unluckyDays_4: unluckyDays4, 82 | name: GachaMap[type] 83 | }) 84 | } 85 | 86 | const all = res.reduce((p, n) => p + n.all, 0) 87 | const number = res.reduce((p, n) => p + n.number, 0) 88 | const validList = res.filter((e) => e.number !== 0) 89 | const times = validList.reduce((p, n) => p + Number(n.times) * n.number, 0) 90 | const count = validList.reduce((p, n) => p + n.number, 0) 91 | const allAverageTimes = times / (count || 1) 92 | 93 | res.push({ 94 | name: '合计', 95 | all, 96 | comment: getComment(allAverageTimes), 97 | times: Math.round(allAverageTimes), 98 | number, 99 | prestige: Math.round(allAverageTimes * 160) 100 | }) 101 | 102 | return res 103 | } 104 | -------------------------------------------------------------------------------- /src/services/getGameRoleCard.ts: -------------------------------------------------------------------------------- 1 | import { API_TAKUMI_RECORD, LINK_BBS_REFERER } from '../constants' 2 | import { getCurrentUser } from '../main/IPC/getCurrentUser' 3 | import { getDS } from '../utils/getDS' 4 | import { getServerByUid } from '../utils/getServerByUid' 5 | import { request } from '../utils/request' 6 | import { qs } from '../utils/utils' 7 | 8 | import type { BaseRes } from '../typings' 9 | 10 | interface Role { 11 | AvatarUrl: string 12 | nickname: string 13 | region: string 14 | level: number 15 | } 16 | 17 | interface Stats { 18 | active_day_number: number 19 | achievement_number: number 20 | anemoculus_number: number 21 | geoculus_number: number 22 | avatar_number: number 23 | way_point_number: number 24 | domain_number: number 25 | spiral_abyss: string 26 | precious_chest_number: number 27 | luxurious_chest_number: number 28 | exquisite_chest_number: number 29 | dendroculus_number: number 30 | common_chest_number: number 31 | electroculus_number: number 32 | magic_chest_number: number 33 | } 34 | 35 | interface Homes { 36 | level: number 37 | visit_num: number 38 | comfort_num: number 39 | item_num: number 40 | name: string 41 | icon: string 42 | comfort_level_name: string 43 | comfort_level_icon: string 44 | } 45 | 46 | interface Offerings { 47 | name: string 48 | level: number 49 | icon: string 50 | } 51 | 52 | interface World_explorations { 53 | level: number 54 | exploration_percentage: number 55 | icon: string 56 | name: string 57 | type: string 58 | offerings: Offerings[] 59 | id: number 60 | parent_id: number 61 | map_url: string 62 | strategy_url: string 63 | background_image: string 64 | inner_icon: string 65 | cover: string 66 | } 67 | 68 | interface Avatars { 69 | id: number 70 | image: string 71 | name: string 72 | element: string 73 | fetter: number 74 | level: number 75 | rarity: number 76 | actived_constellation_num: number 77 | card_image: string 78 | is_chosen: boolean 79 | } 80 | 81 | export interface GameRoleCardData { 82 | role: Role 83 | avatars: Avatars[] 84 | stats: Stats 85 | city_explorations: any[] 86 | world_explorations: World_explorations[] 87 | homes: Homes[] 88 | } 89 | 90 | export async function getGameRoleCard(uid?: string) { 91 | const currentUser = getCurrentUser() 92 | 93 | if (!currentUser) { 94 | return null 95 | } 96 | 97 | const targetUid = uid || currentUser.uid 98 | 99 | const url = `${API_TAKUMI_RECORD}/game_record/app/genshin/api/index` 100 | const params = { role_id: targetUid, server: getServerByUid(targetUid) } 101 | const headers = { 102 | referer: LINK_BBS_REFERER, 103 | DS: getDS(qs(params)), 104 | cookie: currentUser.cookie 105 | } 106 | 107 | const config = { headers, params } 108 | 109 | const { status, data } = await request.get>(url, config) 110 | 111 | if (status !== 200 || data?.retcode !== 0) { 112 | console.log('getGameRoleCard: ', data) 113 | } 114 | 115 | return data 116 | } 117 | -------------------------------------------------------------------------------- /src/services/getSpiralAbyss.ts: -------------------------------------------------------------------------------- 1 | import { API_TAKUMI_RECORD, LINK_BBS_REFERER } from '../constants' 2 | import { getCurrentUser } from '../main/IPC/getCurrentUser' 3 | import { getDS } from '../utils/getDS' 4 | import { getServerByUid } from '../utils/getServerByUid' 5 | import { request } from '../utils/request' 6 | import { qs } from '../utils/utils' 7 | 8 | import type { BaseRes } from '../typings' 9 | 10 | interface Avatars { 11 | id: number 12 | icon: string 13 | level: number 14 | rarity: number 15 | } 16 | interface Battles { 17 | index: number 18 | timestamp: string 19 | avatars: Avatars[] 20 | } 21 | interface Levels { 22 | index: number 23 | star: number 24 | max_star: number 25 | battles: Battles[] 26 | } 27 | interface Floors { 28 | index: number 29 | icon: string 30 | is_unlock: boolean 31 | settle_time: string 32 | star: number 33 | max_star: number 34 | levels: Levels[] 35 | } 36 | interface Energy_skill_rank { 37 | avatar_id: number 38 | avatar_icon: string 39 | value: number 40 | rarity: number 41 | } 42 | interface Normal_skill_rank { 43 | avatar_id: number 44 | avatar_icon: string 45 | value: number 46 | rarity: number 47 | } 48 | 49 | interface Take_damage_rank { 50 | avatar_id: number 51 | avatar_icon: string 52 | value: number 53 | rarity: number 54 | } 55 | 56 | interface Damage_rank { 57 | avatar_id: number 58 | avatar_icon: string 59 | value: number 60 | rarity: number 61 | } 62 | 63 | interface Defeat_rank { 64 | avatar_id: number 65 | avatar_icon: string 66 | value: number 67 | rarity: number 68 | } 69 | 70 | interface Reveal_rank { 71 | avatar_id: number 72 | avatar_icon: string 73 | value: number 74 | rarity: number 75 | } 76 | 77 | export interface SpiralAbyssData { 78 | schedule_id: number 79 | start_time: string 80 | end_time: string 81 | total_battle_times: number 82 | total_win_times: number 83 | max_floor: string 84 | reveal_rank: Reveal_rank[] 85 | defeat_rank: Defeat_rank[] 86 | damage_rank: Damage_rank[] 87 | take_damage_rank: Take_damage_rank[] 88 | normal_skill_rank: Normal_skill_rank[] 89 | energy_skill_rank: Energy_skill_rank[] 90 | floors: Floors[] 91 | total_star: number 92 | is_unlock: boolean 93 | } 94 | 95 | export async function getSpiralAbyss(uid?: string, last = false) { 96 | const currentUser = getCurrentUser() 97 | 98 | if (!currentUser) { 99 | return null 100 | } 101 | 102 | const targetUid = uid || currentUser.uid 103 | 104 | const url = `${API_TAKUMI_RECORD}/game_record/app/genshin/api/spiralAbyss` 105 | const params = { 106 | role_id: targetUid, 107 | schedule_type: last ? '2' : '1', 108 | server: getServerByUid(targetUid) 109 | } 110 | const headers = { 111 | referer: LINK_BBS_REFERER, 112 | DS: getDS(qs(params)), 113 | cookie: currentUser.cookie 114 | } 115 | 116 | const { status, data } = await request.get>(url, { 117 | headers, 118 | params 119 | }) 120 | 121 | if (status !== 200 || data?.retcode !== 0) { 122 | console.log('getSpiralAbyss: ', data) 123 | } 124 | 125 | return data 126 | } 127 | -------------------------------------------------------------------------------- /src/main/initTray.ts: -------------------------------------------------------------------------------- 1 | import { app, Menu, Tray, nativeImage } from 'electron' 2 | import path from 'node:path' 3 | 4 | import { store, isAppleDevice, isWindows } from '.' 5 | import { subWins } from './IPC/openWindow' 6 | import icon from '../assets/icon.ico' 7 | import macicon from '../assets/macicon.png' 8 | import { AppName } from '../constants' 9 | 10 | import type { BrowserWindow, MenuItemConstructorOptions } from 'electron' 11 | 12 | export const Menus: Record = { 13 | openMainWindow: '打开助手', 14 | alwaysOnTop: '置顶显示', 15 | openDevTools: 'DevTools', 16 | hideMainWindow: '隐藏主界面', 17 | openSetting: '设置', 18 | quit: '退出' 19 | } 20 | 21 | /** 初始化托盘图标与菜单 */ 22 | export function initTray(win: BrowserWindow) { 23 | // 图标路径 24 | const dir = path.join(__dirname, isAppleDevice ? macicon : icon) 25 | 26 | // 从路径新建图片 27 | const image = nativeImage.createFromPath(dir) 28 | 29 | // 设置图片为自动适应模式的黑白图标 30 | if (isAppleDevice) { 31 | image.setTemplateImage(true) 32 | } 33 | 34 | // 初始化托盘图标 35 | const tray = new Tray(image) 36 | 37 | // 主窗口的 webContents 用于控制 DevTools 的开关 38 | const web = win.webContents 39 | 40 | // 定义托盘菜单 41 | const menus: MenuItemConstructorOptions[] = [ 42 | // 显示主程序 43 | { 44 | label: Menus.openMainWindow, 45 | click: () => win.show(), 46 | accelerator: 'CommandOrControl+Q' 47 | }, 48 | // 置顶菜单 49 | { 50 | label: Menus.alwaysOnTop, 51 | type: 'checkbox', 52 | // visible: isDev, 53 | checked: store.get('settings.alwaysOnTop'), 54 | click: () => { 55 | const targetValue = !win.isAlwaysOnTop() 56 | store.set('settings.alwaysOnTop', targetValue) 57 | win.setAlwaysOnTop(targetValue) 58 | subWins.forEach((e) => e.setAlwaysOnTop(targetValue)) 59 | } 60 | }, 61 | // 切换 DevTools 开启状态 62 | { 63 | label: Menus.openDevTools, 64 | // visible: isDev, 65 | click: () => web.openDevTools({ mode: 'detach' }) 66 | }, 67 | // 打开设置 68 | { 69 | label: Menus.openSetting, 70 | // visible: isDev, 71 | click: () => { 72 | const url = web.getURL() 73 | const target = `${url.split('#')[0]}#/setting` 74 | win.loadURL(target) 75 | win.show() 76 | } 77 | }, 78 | // 退出 79 | { 80 | label: Menus.quit, 81 | role: 'close', 82 | click: () => app.exit() 83 | } 84 | ] 85 | 86 | // 生成菜单 87 | const contextMenu = Menu.buildFromTemplate(menus) 88 | 89 | // 设置托盘菜单提示文字 90 | tray.setToolTip(`${AppName.zh} v${app.getVersion()}`) 91 | 92 | // 监听点击事件,绑定程序的显示与隐藏操作 93 | // tray.on("click", () => (win.isVisible() && !win.isMinimized() ? win.hide() : win.show())); 94 | tray.on('click', () => isWindows && win.show()) 95 | 96 | // 双击显示主界面 97 | tray.on('double-click', () => win.show()) 98 | 99 | // 加载托盘右键菜单 100 | tray.setContextMenu(contextMenu) 101 | 102 | // 监听即将退出的事件,销毁托盘图标与菜单 103 | app.on('before-quit', () => tray.destroy()) 104 | 105 | // 当置顶状态发生改变时,将状态写入 Store,同时及时刷新菜单的状态显示 106 | win.on('always-on-top-changed', (_, onTop) => { 107 | contextMenu.items[1].checked = onTop 108 | // 刷新托盘右键菜单 109 | tray.setContextMenu(contextMenu) 110 | store.set('settings.alwaysOnTop', onTop) 111 | }) 112 | } 113 | -------------------------------------------------------------------------------- /src/render/pages/statistic/SpiralAbyssTab/index.less: -------------------------------------------------------------------------------- 1 | @import '../../../utils/colors.less'; 2 | @import '../../../utils/utils.less'; 3 | 4 | .spiral-abyss { 5 | .flex-center-column(); 6 | 7 | height: 100%; 8 | 9 | .row { 10 | .ani-show-top(); 11 | .flex-center-y(); 12 | 13 | width: 100%; 14 | justify-content: space-around; 15 | } 16 | 17 | .detail { 18 | width: 100%; 19 | height: 392px; 20 | overflow: auto; 21 | 22 | &::-webkit-scrollbar-thumb { 23 | border-radius: 2px; 24 | background-color: @color-primary; 25 | } 26 | 27 | &::-webkit-scrollbar { 28 | display: none; 29 | width: 6px; 30 | } 31 | 32 | .abyss-item { 33 | .ani-show-bottom(); 34 | .transition-quick(); 35 | .flex-center-y(); 36 | 37 | padding: 0 4px; 38 | border-radius: 8px; 39 | border: 3px solid @color-second; 40 | height: 80px; 41 | margin: 6px 8px 8px 8px; 42 | 43 | &:hover { 44 | border-color: @color-primary; 45 | background-color: rgba(232, 232, 232, 0.6); 46 | 47 | .abyss-index { 48 | border-color: @color-primary; 49 | } 50 | } 51 | 52 | .abyss-index { 53 | .transition-quick(); 54 | .flex-center(); 55 | 56 | border: 3px solid @color-second; 57 | border-left: none; 58 | height: 80px; 59 | width: $height; 60 | 61 | & > div { 62 | .flex(); 63 | 64 | justify-content: center; 65 | align-items: baseline; 66 | 67 | & > span { 68 | color: @color-second; 69 | margin: 0 2px; 70 | } 71 | 72 | & > span:nth-child(2) { 73 | color: @color-primary; 74 | font-size: 32px; 75 | font-weight: bold; 76 | } 77 | } 78 | } 79 | 80 | .zones { 81 | .flex-center-y(); 82 | 83 | flex: 1; 84 | justify-content: flex-start; 85 | 86 | .zone { 87 | .flex-center(); 88 | 89 | margin-left: 12px; 90 | 91 | .stars { 92 | .flex-center-column(); 93 | 94 | height: 80px; 95 | width: 108px; 96 | 97 | & > div:nth-child(2) { 98 | .flex-center-column(); 99 | } 100 | 101 | span { 102 | font-size: 9px; 103 | color: @color-second; 104 | } 105 | 106 | img { 107 | .transition(); 108 | 109 | height: 24px; 110 | 111 | &:hover { 112 | transform: scale(1.06); 113 | } 114 | } 115 | } 116 | } 117 | 118 | .roles { 119 | .flex-center-column(); 120 | 121 | .roles-row { 122 | .flex-center-y(); 123 | } 124 | 125 | img { 126 | .transition(); 127 | 128 | margin: 2px; 129 | width: 34px; 130 | border-radius: 3px; 131 | 132 | &:hover { 133 | transform: scale(1.06); 134 | } 135 | } 136 | } 137 | } 138 | } 139 | } 140 | 141 | .tip { 142 | .ani-show-bottom(); 143 | 144 | bottom: 16px; 145 | color: @color-second; 146 | font-size: 14px; 147 | left: 28px; 148 | position: absolute; 149 | } 150 | 151 | .none { 152 | .ani-show-bottom(); 153 | 154 | color: @color-second; 155 | } 156 | } 157 | --------------------------------------------------------------------------------