mirror of
https://ghproxy.com/https://github.com/StreakingMan/solvable-sheep-game
synced 2025-06-04 22:36:06 +08:00
Compare commits
29 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
09e43ae02a | ||
![]() |
c5bd3f01d9 | ||
![]() |
be0faff9fd | ||
![]() |
cebf64847f | ||
![]() |
ffbb38495f | ||
![]() |
e553e5360f | ||
![]() |
d234bd06a8 | ||
![]() |
680811c50c | ||
![]() |
ffabf6805f | ||
![]() |
58bff1ce98 | ||
![]() |
cd5501ebea | ||
![]() |
331b96c8f0 | ||
![]() |
c6671038a8 | ||
![]() |
5579d5a32e | ||
![]() |
8dec5b6420 | ||
![]() |
3913982716 | ||
![]() |
5353a40416 | ||
![]() |
fd1d429f59 | ||
![]() |
81503973a7 | ||
![]() |
acb5083e22 | ||
![]() |
639b1a3db1 | ||
![]() |
a8a6312e2a | ||
![]() |
9682d31c49 | ||
![]() |
01d85610eb | ||
![]() |
d070c67d3f | ||
![]() |
fc7a880607 | ||
![]() |
47caa7ccf9 | ||
![]() |
3fa7059272 | ||
![]() |
cc06bb14e3 |
0
.husky/commit-msg
Executable file → Normal file
0
.husky/commit-msg
Executable file → Normal file
0
.husky/pre-commit
Executable file → Normal file
0
.husky/pre-commit
Executable file → Normal file
43
CHANGELOG.md
43
CHANGELOG.md
|
@ -2,6 +2,49 @@
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
||||||
|
|
||||||
|
## [1.1.0](https://github.com/StreakingMan/solvable-sheep-game/compare/v1.0.1...v1.1.0) (2023-05-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* 本地图片文件压缩 ([ffbb384](https://github.com/StreakingMan/solvable-sheep-game/commit/ffbb38495f2c8c124f6039496f613e6c416d9db4))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* 图片文件大小校验逻辑 ([be0faff](https://github.com/StreakingMan/solvable-sheep-game/commit/be0faff9fdf4aea6a2659325520d68b2f348c656))
|
||||||
|
* 自定义主题编辑回显 ([ffabf68](https://github.com/StreakingMan/solvable-sheep-game/commit/ffabf6805f81dd03bd070dd09a32e51a51d1138f))
|
||||||
|
* 音频文件切换为minio外链 ([680811c](https://github.com/StreakingMan/solvable-sheep-game/commit/680811c50cbef3fec80b73407daddcf48847f3bd))
|
||||||
|
|
||||||
|
### [1.0.1](https://github.com/StreakingMan/solvable-sheep-game/compare/v1.0.0...v1.0.1) (2022-10-19)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* diy模式得分页再来一次按钮显示 ([c667103](https://github.com/StreakingMan/solvable-sheep-game/commit/c6671038a831fd069b930303aa3bc841b5317571))
|
||||||
|
* 主题配置表单样式优化 ([5579d5a](https://github.com/StreakingMan/solvable-sheep-game/commit/5579d5a32e87a37c50d33621551dd39f7a0c74e5))
|
||||||
|
* 主题配置表单样式优化 ([8dec5b6](https://github.com/StreakingMan/solvable-sheep-game/commit/8dec5b6420b256ee6d87689d6790d4da81171913))
|
||||||
|
* 排行榜滚动定位 ([5353a40](https://github.com/StreakingMan/solvable-sheep-game/commit/5353a4041615589ac57d0cd8ce1ebca49372211c))
|
||||||
|
* 纯净模式隐藏二维码 ([3913982](https://github.com/StreakingMan/solvable-sheep-game/commit/3913982716d570d11afbeacdad8dd5d6d522eb37))
|
||||||
|
* 通关成功状态判断,完成游戏后无法点击问题 ([331b96c](https://github.com/StreakingMan/solvable-sheep-game/commit/331b96c8f0717bbb7912984b38cfde8622ea9bc6))
|
||||||
|
|
||||||
|
## [1.0.0](https://github.com/StreakingMan/solvable-sheep-game/compare/v0.0.10...v1.0.0) (2022-10-12)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* 排行榜 ([9682d31](https://github.com/StreakingMan/solvable-sheep-game/commit/9682d31c495d8c7229003e68b79a15430a4ecb68))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* wxQrcode路径 ([cc06bb1](https://github.com/StreakingMan/solvable-sheep-game/commit/cc06bb14e3a89411478dc32e880a49ef692b2e3c))
|
||||||
|
* 主题ID恢复为一次性,交互优化 ([d070c67](https://github.com/StreakingMan/solvable-sheep-game/commit/d070c67d3f49d8dbf89d420b72d02896a2d49508))
|
||||||
|
* 分数缓存逻辑调整 ([a8a6312](https://github.com/StreakingMan/solvable-sheep-game/commit/a8a6312e2a5d67639f50e185407915d554d28127))
|
||||||
|
* 图标路径判断问题 ([fc7a880](https://github.com/StreakingMan/solvable-sheep-game/commit/fc7a8806078cf0c1c343830cb3c0a3ba3aa7cbf0))
|
||||||
|
* 排行榜样式调整 ([639b1a3](https://github.com/StreakingMan/solvable-sheep-game/commit/639b1a3db11d3f21d88e39e8408fadab7dfa4121))
|
||||||
|
* 自定义主题icons完成度校验 ([01d8561](https://github.com/StreakingMan/solvable-sheep-game/commit/01d85610ebb2f3dcc7da7b2fa038c168414fc49a))
|
||||||
|
|
||||||
### [0.0.10](https://github.com/StreakingMan/solvable-sheep-game/compare/v0.0.9...v0.0.10) (2022-10-11)
|
### [0.0.10](https://github.com/StreakingMan/solvable-sheep-game/compare/v0.0.9...v0.0.10) (2022-10-11)
|
||||||
|
|
||||||
|
|
||||||
|
|
29
README.md
29
README.md
|
@ -19,36 +19,34 @@
|
||||||
**声明:本项目仅供交流,禁止商用!否则后果自负。基于此项目的二创都是欢迎的,但非二创请不要删除原仓库地址
|
**声明:本项目仅供交流,禁止商用!否则后果自负。基于此项目的二创都是欢迎的,但非二创请不要删除原仓库地址
|
||||||
(啥都不改唯独删除来源我真的会谢 🙄️,请尊重他人劳动成果)**
|
(啥都不改唯独删除来源我真的会谢 🙄️,请尊重他人劳动成果)**
|
||||||
|
|
||||||

|
<img src="qrcode.png" style="width: 250px;" alt="体验地址二维码">
|
||||||
|
|
||||||
## Feature
|
## Feature
|
||||||
|
|
||||||
- 弹出:弹出队列左侧第一个,无限次数
|
- 弹出:弹出队列左侧第一个,无限次数
|
||||||
- 撤销:撤销上一次操作,无限次数
|
- 撤销:撤销上一次操作,无限次数
|
||||||
- 洗牌:哗啦哗啦,无限次数
|
- 洗牌:哗啦哗啦,无限次数
|
||||||
- 关卡:50 关玩到爽,可直接跳
|
- 关卡:20 关玩到爽,可直接跳
|
||||||
- 内置主题:金轮<img style="width:36px" src="src/themes/jinlun/images/肌肉金轮1.png" />、
|
- 内置主题:金轮<img style="width:36px" src="src/themes/jinlun/images/肌肉金轮1.png" />、
|
||||||
骚猪<img style="width:36px" src="src/themes/pdd/images/1.png" />、
|
骚猪<img style="width:36px" src="src/themes/pdd/images/1.png" />、
|
||||||
ikun<img style="width:36px" src="src/themes/ikun/images/kun.png" />(露出黑脚)等
|
ikun<img style="width:36px" src="src/themes/ikun/images/kun.png" />(露出黑脚)等
|
||||||
- 自定义主题:自定义图片和音效,快速整活
|
- 自定义主题:自定义图片和音效,快速整活
|
||||||
|
- 排行榜:皇城 pk
|
||||||
|
|
||||||
开心就好 😄
|
开心就好 😄
|
||||||
|
|
||||||

|

|
||||||

|
|
||||||
|
|
||||||
## Contribution
|
## Contribution
|
||||||
|
|
||||||
vite+react 实现,欢迎 star、issue、pr、fork(尽量标注原仓库地址)
|
vite+react 实现,欢迎 star、issue、pr、fork(尽量标注原仓库地址)
|
||||||
|
|
||||||
切换主题参考 `src/themes` 下的代码,欢迎整活
|
|
||||||
|
|
||||||
## Related Repo
|
## Related Repo
|
||||||
|
|
||||||
<a href="https://github.com/opendilab" target="_blank">opendilab</a> 的 AI 整活!移步
|
<a href="https://github.com/opendilab" target="_blank">opendilab</a> 的 AI 整活!移步
|
||||||
<a href="https://github.com/opendilab/DI-sheep" target="_blank">DI-sheep:深度强化学习 + 羊了个羊</a>
|
<a href="https://github.com/opendilab/DI-sheep" target="_blank">DI-sheep:深度强化学习 + 羊了个羊</a>
|
||||||
|
|
||||||
<img style="width:360px" src="https://github.com/opendilab/DI-sheep/raw/master/ui/public/demo.gif" alt="" />
|
<img style="width:250px" src="https://github.com/opendilab/DI-sheep/raw/master/ui/public/demo.gif" alt="" />
|
||||||
|
|
||||||
## Todo List
|
## Todo List
|
||||||
|
|
||||||
|
@ -57,21 +55,30 @@ vite+react 实现,欢迎 star、issue、pr、fork(尽量标注原仓库地
|
||||||
- [x] UI/UX 优化
|
- [x] UI/UX 优化
|
||||||
- [x] 多主题
|
- [x] 多主题
|
||||||
- [x] 计时、得分、保存进度机制
|
- [x] 计时、得分、保存进度机制
|
||||||
- [ ] 排行榜
|
- [x] 排行榜
|
||||||
- [ ] 性能优化
|
- [ ] 性能优化
|
||||||
- [x] BGM/音效
|
- [x] BGM/音效
|
||||||
- [ ] ~~点击时的缓冲队列,优化交互动画效果~~
|
- [ ] ~~点击时的缓冲队列,优化交互动画效果~~
|
||||||
- [x] 该游戏似乎涉嫌抄袭,考证后补充来源说明
|
- [x] 该游戏似乎涉嫌抄袭,考证后补充来源说明
|
||||||
- [ ] 桌面应用
|
- [ ] ~~桌面应用~~
|
||||||
- [x] 路径区分主题
|
- [x] 路径区分主题
|
||||||
- [x] 主题自定义
|
- [x] 主题自定义
|
||||||
- [x] 本地图片、音频配置
|
- [x] 本地图片、音频配置
|
||||||
|
|
||||||
## 二次开发
|
## 二次开发
|
||||||
|
|
||||||
项目的自定义主题功能设计到后台存储(Bmob 懒人数据库),如果您只是简单的整活,可能并不需要相关的逻辑。
|
项目的自定义主题功能涉及到后台存储(Bmob 懒人数据库),如果您只是简单的整活,可能并不需要相关的逻辑。
|
||||||
详细的二次开发说面移步这里[DIY 指南](/diy/README.md)
|
详细的二次开发说明请移步这里[DIY 指南](/diy/README.md)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[GNU GENERAL PUBLIC LICENSE Version 3](LICENSE.md)
|
[GNU GENERAL PUBLIC LICENSE Version 3](LICENSE.md)
|
||||||
|
|
||||||
|
## 资助
|
||||||
|
|
||||||
|
~~由于各种白嫖的静态资源托管、后台服务的免费额度都已用完,目前自费升级了相关套餐。~~
|
||||||
|
如果您喜欢这个项目,觉得本项目对你有帮助的话,可以扫描下方付款码请我喝杯咖啡 ☕️/~~分摊后台服务费用~~~ 😘
|
||||||
|
|
||||||
|
2023.5.5 更新:Bmob 服务到期,后台服务已下线,相关功能暂时无法使用,如有需要请自行搭建后台服务
|
||||||
|
|
||||||
|

|
||||||
|
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
游戏的核心逻辑已经封装到了 `src/components/Game.tsx` ,方便大家魔改, 主题配置的类型声明见 `src/themes/interface.ts`
|
游戏的核心逻辑已经封装到了 `src/components/Game.tsx` ,方便大家魔改, 主题配置的类型声明见 `src/themes/interface.ts`
|
||||||
|
|
||||||
|
你可以先通过超酷的[stackblitz](https://stackblitz.com/edit/solvable-sheep-game?file=diy%2Fdiy.theme.json&terminal=dev:diy)
|
||||||
|
在线体验一番(等待依赖安装完成后,编辑配置将实时更新)再回来看这里的指南。
|
||||||
|
|
||||||
## 准备工作
|
## 准备工作
|
||||||
|
|
||||||
### 环境准备
|
### 环境准备
|
||||||
|
@ -24,10 +27,11 @@ yarn install
|
||||||
|
|
||||||
## 素材配置
|
## 素材配置
|
||||||
|
|
||||||
原项目的自定义后台使用了 Bmob 后台,DIY 时并不需要相关的逻辑,您只需要改动 `diy` 文件下的文件: 图片以及音频素材复制到 `diy/public` 下, 并配置 `diy/diy.theme.json`
|
原项目的自定义主题功能使用了 Bmob 后台,DIY 时并不需要相关的逻辑,您只需要改动 `diy` 文件下的文件:
|
||||||
|
图片以及音频素材复制到 `diy/public` 下, 并配置 `diy/diy.theme.json`
|
||||||
文件即可,配置格式见 `src/themes/interface.ts` ,
|
文件即可,配置格式见 `src/themes/interface.ts` ,
|
||||||
|
|
||||||
以下是配置字段的说明:
|
以下是`diy.theme.json`配置字段的说明:
|
||||||
|
|
||||||
- title 标题
|
- title 标题
|
||||||
- desc 描述
|
- desc 描述
|
||||||
|
@ -67,9 +71,10 @@ yarn dev:diy
|
||||||
yarn build:diy
|
yarn build:diy
|
||||||
```
|
```
|
||||||
|
|
||||||
会在 `diy/diy-dist` 下,生成静态资源,直接将这些文件复制服务器上代理即可。如果嫌麻烦,推荐使用 [vercel](https://vercel.com/) 一键部署(每月免费 100G 流量), 将更改后的项目推到自己的
|
会在 `diy/diy-dist` 下生成静态资源,直接将这些文件复制服务器上做代理即可。如果嫌麻烦,推荐使用 [vercel](https://vercel.com/)
|
||||||
github(gitlab,bitbucket 同样支持)仓库, 使用 github 账号登录 vercel 后导入该项目,构建模版选择 vite, 构建命令更改为 `yarn build:diy`
|
一键部署(每月免费 100G 流量), 将更改后的项目推到自己的 github(gitlab,bitbucket 同样支持)仓库,
|
||||||
输出地址改为 `diy/diy-dist` 即可 。
|
使用 github 账号登录 vercel 后导入该项目,构建模版选择 vite,
|
||||||
|
构建命令更改为 `yarn build:diy` 输出地址改为 `diy/diy-dist` 即可 。见下图:
|
||||||
|
|
||||||
<img src="./vercel.png" alt="" style="width: 400px"/>
|
<img src="./vercel.png" alt="" style="width: 400px"/>
|
||||||
|
|
||||||
|
@ -77,7 +82,8 @@ github(gitlab,bitbucket 同样支持)仓库, 使用 github 账号登录
|
||||||
|
|
||||||
## 其他
|
## 其他
|
||||||
|
|
||||||
如果您想体验项目的完整功能,则需要注册一个 [Bmob](https://www.bmobapp.com/) 账号, 注册后新建应用(有一年的白嫖版,免费请求数虽然很客观,但并发数有限制,请根据自己的实际流量
|
如果您想体验项目的完整功能,则需要注册一个 [Bmob](https://www.bmobapp.com/) 账号,
|
||||||
|
注册后新建应用(有一年的白嫖版,免费请求数虽然很客观,但并发数有限制,请根据自己的实际流量
|
||||||
选择升级套餐,或者其他存储方案)
|
选择升级套餐,或者其他存储方案)
|
||||||
|
|
||||||
新建应用后,去到设置页面拷贝密钥和安全码到项目的 `.env` 文件中
|
新建应用后,去到设置页面拷贝密钥和安全码到项目的 `.env` 文件中
|
||||||
|
@ -91,7 +97,7 @@ ps: 如果您的项目托管在公共仓库中,请注意保护密钥,本地
|
||||||
|
|
||||||
`config` 表用来存储自定义配置的 json 字符串,需要新增 `content` 列
|
`config` 表用来存储自定义配置的 json 字符串,需要新增 `content` 列
|
||||||
|
|
||||||
`file` 表则是为了 vercel 流量,将一些默认文件转为 base64 编码存到了数据库中,需要添加三列
|
`rank` 表,储存排名信息
|
||||||

|

|
||||||
|
|
||||||
最后,开发和打包命令分别使用 `yarn dev` 和 `yarn build` 即可
|
最后,开发和打包命令分别使用 `yarn dev` 和 `yarn build` 即可
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 58 KiB |
BIN
diy/datebase-rank.png
Normal file
BIN
diy/datebase-rank.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 56 KiB |
|
@ -6,7 +6,7 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="无限道具版羊了个羊、可以通关的羊了个羊"
|
content="无限道具版羊了个羊、可以通关的羊了个羊、羊了个羊生成器"
|
||||||
/>
|
/>
|
||||||
<title>有解的羊了个羊</title>
|
<title>有解的羊了个羊</title>
|
||||||
<script>
|
<script>
|
||||||
|
@ -213,8 +213,8 @@
|
||||||
'https://github.com/StreakingMan/solvable-sheep-game'
|
'https://github.com/StreakingMan/solvable-sheep-game'
|
||||||
);
|
);
|
||||||
a.setAttribute('target', '_self');
|
a.setAttribute('target', '_self');
|
||||||
a.style.cursor = 'pointer'
|
a.style.cursor = 'pointer';
|
||||||
a.style.zIndex = '15'
|
a.style.zIndex = '15';
|
||||||
a.innerText =
|
a.innerText =
|
||||||
'本项目仅供交流,禁止商业用途,点击查看原github仓库';
|
'本项目仅供交流,禁止商业用途,点击查看原github仓库';
|
||||||
document.getElementById('root')?.prepend(a);
|
document.getElementById('root')?.prepend(a);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "solvable-sheep-game",
|
"name": "solvable-sheep-game",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "0.0.10",
|
"version": "1.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|
BIN
preview.png
BIN
preview.png
Binary file not shown.
Before Width: | Height: | Size: 212 KiB |
BIN
preview2.png
BIN
preview2.png
Binary file not shown.
Before Width: | Height: | Size: 27 KiB |
BIN
previews.png
Normal file
BIN
previews.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 372 KiB |
25
src/App.tsx
25
src/App.tsx
|
@ -5,6 +5,8 @@ import {
|
||||||
LAST_LEVEL_STORAGE_KEY,
|
LAST_LEVEL_STORAGE_KEY,
|
||||||
LAST_SCORE_STORAGE_KEY,
|
LAST_SCORE_STORAGE_KEY,
|
||||||
LAST_TIME_STORAGE_KEY,
|
LAST_TIME_STORAGE_KEY,
|
||||||
|
PLAYING_THEME_ID_STORAGE_KEY,
|
||||||
|
resetScoreStorage,
|
||||||
wrapThemeDefaultSounds,
|
wrapThemeDefaultSounds,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import { Theme } from './themes/interface';
|
import { Theme } from './themes/interface';
|
||||||
|
@ -17,11 +19,6 @@ const ThemeChanger = React.lazy(() => import('./components/ThemeChanger'));
|
||||||
const ConfigDialog = React.lazy(() => import('./components/ConfigDialog'));
|
const ConfigDialog = React.lazy(() => import('./components/ConfigDialog'));
|
||||||
const WxQrCode = React.lazy(() => import('./components/WxQrCode'));
|
const WxQrCode = React.lazy(() => import('./components/WxQrCode'));
|
||||||
|
|
||||||
// 读取缓存关卡得分
|
|
||||||
const initLevel = Number(localStorage.getItem(LAST_LEVEL_STORAGE_KEY) || '1');
|
|
||||||
const initScore = Number(localStorage.getItem(LAST_SCORE_STORAGE_KEY) || '0');
|
|
||||||
const initTime = Number(localStorage.getItem(LAST_TIME_STORAGE_KEY) || '0');
|
|
||||||
|
|
||||||
const App: FC<{ theme: Theme<any> }> = ({ theme: initTheme }) => {
|
const App: FC<{ theme: Theme<any> }> = ({ theme: initTheme }) => {
|
||||||
console.log('initTheme', initTheme);
|
console.log('initTheme', initTheme);
|
||||||
// console.log(JSON.stringify(theme));
|
// console.log(JSON.stringify(theme));
|
||||||
|
@ -29,7 +26,23 @@ const App: FC<{ theme: Theme<any> }> = ({ theme: initTheme }) => {
|
||||||
const [theme, setTheme] = useState<Theme<any>>(initTheme);
|
const [theme, setTheme] = useState<Theme<any>>(initTheme);
|
||||||
const [diyDialogShow, setDiyDialogShow] = useState<boolean>(false);
|
const [diyDialogShow, setDiyDialogShow] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// 读取缓存关卡得分
|
||||||
|
const [initLevel, setInitLevel] = useState<number>(
|
||||||
|
Number(localStorage.getItem(LAST_LEVEL_STORAGE_KEY) || '1')
|
||||||
|
);
|
||||||
|
const [initScore, setInitScore] = useState<number>(
|
||||||
|
Number(localStorage.getItem(LAST_SCORE_STORAGE_KEY) || '0')
|
||||||
|
);
|
||||||
|
const [initTime, setInitTime] = useState<number>(
|
||||||
|
Number(localStorage.getItem(LAST_TIME_STORAGE_KEY) || '0')
|
||||||
|
);
|
||||||
|
|
||||||
const changeTheme = (theme: Theme<any>) => {
|
const changeTheme = (theme: Theme<any>) => {
|
||||||
|
localStorage.setItem(PLAYING_THEME_ID_STORAGE_KEY, theme.title);
|
||||||
|
setInitLevel(1);
|
||||||
|
setInitScore(0);
|
||||||
|
setInitTime(0);
|
||||||
|
resetScoreStorage();
|
||||||
wrapThemeDefaultSounds(theme);
|
wrapThemeDefaultSounds(theme);
|
||||||
domRelatedOptForTheme(theme);
|
domRelatedOptForTheme(theme);
|
||||||
setTheme({ ...theme });
|
setTheme({ ...theme });
|
||||||
|
@ -77,7 +90,7 @@ const App: FC<{ theme: Theme<any> }> = ({ theme: initTheme }) => {
|
||||||
<PersonalInfo />
|
<PersonalInfo />
|
||||||
<div className={'flex-spacer'} style={{ minHeight: 52 }} />
|
<div className={'flex-spacer'} style={{ minHeight: 52 }} />
|
||||||
<Suspense fallback={<span>Loading</span>}>
|
<Suspense fallback={<span>Loading</span>}>
|
||||||
{!__DIY__ && <WxQrCode />}
|
{!__DIY__ && !theme.pure && <WxQrCode />}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
{!__DIY__ && (
|
{!__DIY__ && (
|
||||||
<p
|
<p
|
||||||
|
|
|
@ -146,7 +146,8 @@
|
||||||
|
|
||||||
input,
|
input,
|
||||||
select {
|
select {
|
||||||
flex-grow: 1;
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='file'] {
|
input[type='file'] {
|
||||||
|
@ -228,7 +229,8 @@
|
||||||
.divider {
|
.divider {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 0;
|
height: 0;
|
||||||
border-bottom: 1px solid rgb(0 0 0 / 8%);
|
border-bottom: 1px solid currentcolor;
|
||||||
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes gradient {
|
@keyframes gradient {
|
||||||
|
@ -260,3 +262,21 @@
|
||||||
animation: gradient 1s ease infinite;
|
animation: gradient 1s ease infinite;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes rotate {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.7) rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: scale(0.7) rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrCode {
|
||||||
|
transition: 0.5s;
|
||||||
|
|
||||||
|
&.uploading {
|
||||||
|
animation: rotate 0.5s infinite linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { FC, ReactNode, useEffect, useRef, useState } from 'react';
|
import React, { FC, ReactNode, useEffect, useState } from 'react';
|
||||||
import style from './ConfigDialog.module.scss';
|
import style from './ConfigDialog.module.scss';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Icon, Sound, Theme } from '../themes/interface';
|
import { Icon, Sound, Theme } from '../themes/interface';
|
||||||
|
@ -7,13 +7,16 @@ import Bmob from 'hydrogen-js-sdk';
|
||||||
import {
|
import {
|
||||||
captureElement,
|
captureElement,
|
||||||
CUSTOM_THEME_FILE_VALIDATE_STORAGE_KEY,
|
CUSTOM_THEME_FILE_VALIDATE_STORAGE_KEY,
|
||||||
CUSTOM_THEME_ID_STORAGE_KEY,
|
LAST_CUSTOM_THEME_ID_STORAGE_KEY,
|
||||||
CUSTOM_THEME_STORAGE_KEY,
|
CUSTOM_THEME_STORAGE_KEY,
|
||||||
deleteThemeUnusedSounds,
|
deleteThemeUnusedSounds,
|
||||||
getFileBase64String,
|
getFileBase64String,
|
||||||
linkReg,
|
linkReg,
|
||||||
randomString,
|
randomString,
|
||||||
wrapThemeDefaultSounds,
|
wrapThemeDefaultSounds,
|
||||||
|
LAST_UPLOAD_TIME_STORAGE_KEY,
|
||||||
|
canvasToFile,
|
||||||
|
createCanvasByImgSrc,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import { copy } from 'clipboard';
|
import { copy } from 'clipboard';
|
||||||
import { CloseIcon } from './CloseIcon';
|
import { CloseIcon } from './CloseIcon';
|
||||||
|
@ -51,8 +54,6 @@ interface CustomTheme extends Theme<any> {
|
||||||
icons: CustomIcon[];
|
icons: CustomIcon[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = localStorage.getItem(CUSTOM_THEME_ID_STORAGE_KEY);
|
|
||||||
|
|
||||||
const ConfigDialog: FC<{
|
const ConfigDialog: FC<{
|
||||||
closeMethod: () => void;
|
closeMethod: () => void;
|
||||||
previewMethod: (theme: Theme<string>) => void;
|
previewMethod: (theme: Theme<string>) => void;
|
||||||
|
@ -60,14 +61,13 @@ const ConfigDialog: FC<{
|
||||||
// 错误提示
|
// 错误提示
|
||||||
const [configError, setConfigError] = useState<string>('');
|
const [configError, setConfigError] = useState<string>('');
|
||||||
// 生成链接
|
// 生成链接
|
||||||
const [genLink, setGenLink] = useState<string>(
|
const [genLink, setGenLink] = useState<string>('');
|
||||||
id ? `${location.origin}?customTheme=${id}` : ''
|
|
||||||
);
|
|
||||||
|
|
||||||
// 主题大对象
|
// 主题大对象
|
||||||
const [customTheme, setCustomTheme] = useState<CustomTheme>({
|
const [customTheme, setCustomTheme] = useState<CustomTheme>({
|
||||||
title: '',
|
title: '',
|
||||||
sounds: [],
|
sounds: [],
|
||||||
|
pure: false,
|
||||||
icons: new Array(10).fill(0).map(() => ({
|
icons: new Array(10).fill(0).map(() => ({
|
||||||
name: randomString(4),
|
name: randomString(4),
|
||||||
content: '',
|
content: '',
|
||||||
|
@ -146,10 +146,14 @@ const ConfigDialog: FC<{
|
||||||
new Array(10).fill('')
|
new Array(10).fill('')
|
||||||
);
|
);
|
||||||
// 文件体积校验开关
|
// 文件体积校验开关
|
||||||
|
const initEnableFileSizeValidate = localStorage.getItem(
|
||||||
|
CUSTOM_THEME_FILE_VALIDATE_STORAGE_KEY
|
||||||
|
);
|
||||||
const [enableFileSizeValidate, setEnableFileSizeValidate] =
|
const [enableFileSizeValidate, setEnableFileSizeValidate] =
|
||||||
useState<boolean>(
|
useState<boolean>(
|
||||||
localStorage.getItem(CUSTOM_THEME_FILE_VALIDATE_STORAGE_KEY) !==
|
initEnableFileSizeValidate === null
|
||||||
'false'
|
? true
|
||||||
|
: initEnableFileSizeValidate === 'true'
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
|
@ -165,7 +169,7 @@ const ConfigDialog: FC<{
|
||||||
type: 'bgm' | 'background' | 'sound' | 'icon';
|
type: 'bgm' | 'background' | 'sound' | 'icon';
|
||||||
file?: File;
|
file?: File;
|
||||||
idx?: number;
|
idx?: number;
|
||||||
}) => void = ({ type, file, idx }) => {
|
}) => void = async ({ type, file, idx }) => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'bgm':
|
case 'bgm':
|
||||||
|
@ -183,16 +187,20 @@ const ConfigDialog: FC<{
|
||||||
break;
|
break;
|
||||||
case 'background':
|
case 'background':
|
||||||
setBackgroundError('');
|
setBackgroundError('');
|
||||||
if (enableFileSizeValidate && file.size > 80 * 1024) {
|
try {
|
||||||
return setBackgroundError('请选择80k以内全损画质的图片');
|
const _file = enableFileSizeValidate
|
||||||
|
? await canvasToFile({
|
||||||
|
canvas: await createCanvasByImgSrc({
|
||||||
|
imgSrc: await getFileBase64String(file),
|
||||||
|
}),
|
||||||
|
maxFileSize: 20 * 1024,
|
||||||
|
})
|
||||||
|
: file;
|
||||||
|
const fileBase64 = await getFileBase64String(_file);
|
||||||
|
updateCustomTheme('background', fileBase64);
|
||||||
|
} catch (e: any) {
|
||||||
|
setBackgroundError(e);
|
||||||
}
|
}
|
||||||
getFileBase64String(file)
|
|
||||||
.then((res) => {
|
|
||||||
updateCustomTheme('background', res);
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
setBackgroundError(e);
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
case 'sound':
|
case 'sound':
|
||||||
setSoundError('');
|
setSoundError('');
|
||||||
|
@ -210,23 +218,27 @@ const ConfigDialog: FC<{
|
||||||
case 'icon':
|
case 'icon':
|
||||||
if (idx == null) return;
|
if (idx == null) return;
|
||||||
setIconErrors(makeIconErrors(idx, ''));
|
setIconErrors(makeIconErrors(idx, ''));
|
||||||
if (enableFileSizeValidate && file.size > 5 * 1024) {
|
try {
|
||||||
return setIconErrors(
|
const _file = enableFileSizeValidate
|
||||||
makeIconErrors(idx, '请选择5k以内的图片文件')
|
? await canvasToFile({
|
||||||
|
canvas: await createCanvasByImgSrc({
|
||||||
|
imgSrc: await getFileBase64String(file),
|
||||||
|
}),
|
||||||
|
maxFileSize: 4 * 1024,
|
||||||
|
})
|
||||||
|
: file;
|
||||||
|
const fileBase64 = await getFileBase64String(_file);
|
||||||
|
updateCustomTheme(
|
||||||
|
'icons',
|
||||||
|
customTheme.icons.map((icon, _idx) =>
|
||||||
|
_idx === idx
|
||||||
|
? { ...icon, content: fileBase64 }
|
||||||
|
: icon
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
setIconErrors(makeIconErrors(idx, e));
|
||||||
}
|
}
|
||||||
getFileBase64String(file)
|
|
||||||
.then((res) => {
|
|
||||||
updateCustomTheme(
|
|
||||||
'icons',
|
|
||||||
customTheme.icons.map((icon, _idx) =>
|
|
||||||
_idx === idx ? { ...icon, content: res } : icon
|
|
||||||
)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
setIconErrors(makeIconErrors(idx, e));
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -246,12 +258,16 @@ const ConfigDialog: FC<{
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const lastId = localStorage.getItem(LAST_CUSTOM_THEME_ID_STORAGE_KEY);
|
||||||
|
lastId && setGenLink(`${location.origin}?customTheme=${lastId}`);
|
||||||
try {
|
try {
|
||||||
const configString = localStorage.getItem(CUSTOM_THEME_STORAGE_KEY);
|
const configString = localStorage.getItem(CUSTOM_THEME_STORAGE_KEY);
|
||||||
if (configString) {
|
if (configString) {
|
||||||
const parseRes = JSON.parse(configString);
|
const parseRes = JSON.parse(configString);
|
||||||
if (typeof parseRes === 'object') {
|
if (typeof parseRes === 'object') {
|
||||||
setCustomTheme(parseRes);
|
setTimeout(() => {
|
||||||
|
setCustomTheme(parseRes);
|
||||||
|
}, 300);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -272,6 +288,15 @@ const ConfigDialog: FC<{
|
||||||
const findIconError = iconErrors.find((i) => !!i);
|
const findIconError = iconErrors.find((i) => !!i);
|
||||||
if (findIconError)
|
if (findIconError)
|
||||||
return Promise.reject(`图标素材有错误:${findIconError}`);
|
return Promise.reject(`图标素材有错误:${findIconError}`);
|
||||||
|
const findUnfinishedIconIdx = customTheme.icons.findIndex(
|
||||||
|
(icon) => !icon.content
|
||||||
|
);
|
||||||
|
if (findUnfinishedIconIdx !== -1) {
|
||||||
|
setIconErrors(makeIconErrors(findUnfinishedIconIdx, '请填写链接'));
|
||||||
|
return Promise.reject(
|
||||||
|
`第${findUnfinishedIconIdx + 1}图标素材未完成`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.resolve('');
|
return Promise.resolve('');
|
||||||
};
|
};
|
||||||
|
@ -301,6 +326,20 @@ const ConfigDialog: FC<{
|
||||||
if (uploading) return;
|
if (uploading) return;
|
||||||
if (!enableFileSizeValidate)
|
if (!enableFileSizeValidate)
|
||||||
return setConfigError('请先开启文件大小校验');
|
return setConfigError('请先开启文件大小校验');
|
||||||
|
let passTime = Number.MAX_SAFE_INTEGER;
|
||||||
|
const lastUploadTime = localStorage.getItem(
|
||||||
|
LAST_UPLOAD_TIME_STORAGE_KEY
|
||||||
|
);
|
||||||
|
if (lastUploadTime) {
|
||||||
|
passTime = Date.now() - Number(lastUploadTime);
|
||||||
|
}
|
||||||
|
if (passTime < 1000 * 60 * 15) {
|
||||||
|
return setConfigError(
|
||||||
|
`为节省请求数,15分钟内只能生成一次二维码,还剩大约${
|
||||||
|
15 - Math.round(passTime / 1000 / 60)
|
||||||
|
}分钟,先本地预览调整下吧~`
|
||||||
|
);
|
||||||
|
}
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
setConfigError('');
|
setConfigError('');
|
||||||
validateTheme()
|
validateTheme()
|
||||||
|
@ -310,19 +349,19 @@ const ConfigDialog: FC<{
|
||||||
const stringify = JSON.stringify(cloneTheme);
|
const stringify = JSON.stringify(cloneTheme);
|
||||||
localStorage.setItem(CUSTOM_THEME_STORAGE_KEY, stringify);
|
localStorage.setItem(CUSTOM_THEME_STORAGE_KEY, stringify);
|
||||||
const query = Bmob.Query('config');
|
const query = Bmob.Query('config');
|
||||||
if (id) query.set('id', id);
|
|
||||||
// Bmob上限 384563
|
|
||||||
query.set('content', stringify);
|
query.set('content', stringify);
|
||||||
query
|
query
|
||||||
.save()
|
.save()
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!id) {
|
localStorage.setItem(
|
||||||
localStorage.setItem(
|
LAST_CUSTOM_THEME_ID_STORAGE_KEY,
|
||||||
CUSTOM_THEME_ID_STORAGE_KEY,
|
//@ts-ignore
|
||||||
//@ts-ignore
|
res.objectId
|
||||||
res.objectId
|
);
|
||||||
);
|
localStorage.setItem(
|
||||||
}
|
LAST_UPLOAD_TIME_STORAGE_KEY,
|
||||||
|
Date.now().toString()
|
||||||
|
);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setGenLink(
|
setGenLink(
|
||||||
`${location.origin}?customTheme=${
|
`${location.origin}?customTheme=${
|
||||||
|
@ -334,11 +373,7 @@ const ConfigDialog: FC<{
|
||||||
})
|
})
|
||||||
.catch(({ error, code }) => {
|
.catch(({ error, code }) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setConfigError(
|
setConfigError(error);
|
||||||
code === 10007
|
|
||||||
? '上传数据长度已超过bmob的限制'
|
|
||||||
: error
|
|
||||||
);
|
|
||||||
}, 3000);
|
}, 3000);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
@ -348,19 +383,37 @@ const ConfigDialog: FC<{
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
setTimeout(() => {
|
setConfigError(e);
|
||||||
setConfigError(e);
|
setUploading(false);
|
||||||
setUploading(false);
|
|
||||||
}, 3000);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 彩蛋
|
||||||
|
const [pureClickTime, setPureClickTime] = useState<number>(0);
|
||||||
|
useEffect(() => {
|
||||||
|
updateCustomTheme(
|
||||||
|
'pure',
|
||||||
|
pureClickTime % 5 === 0 && pureClickTime !== 0
|
||||||
|
);
|
||||||
|
}, [pureClickTime]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(style.dialog)}>
|
<div className={classNames(style.dialog)}>
|
||||||
<div className={style.closeBtn} onClick={closeMethod}>
|
<div className={style.closeBtn} onClick={closeMethod}>
|
||||||
<CloseIcon fill={'#fff'} />
|
<CloseIcon fill={'#fff'} />
|
||||||
</div>
|
</div>
|
||||||
<h2>自定义主题</h2>
|
<h2>自定义主题</h2>
|
||||||
|
<p style={{ color: 'red' }}>
|
||||||
|
后台服务到期,分享链接功能已停用,有需要的同学参考
|
||||||
|
<a
|
||||||
|
href="https://github.com/StreakingMan/solvable-sheep-game/blob/master/diy/README.md"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
👉这里👈
|
||||||
|
</a>
|
||||||
|
自行部署,感谢支持!🙏
|
||||||
|
</p>
|
||||||
|
|
||||||
<InputContainer label={'标题'} required>
|
<InputContainer label={'标题'} required>
|
||||||
<input
|
<input
|
||||||
|
@ -378,7 +431,7 @@ const ConfigDialog: FC<{
|
||||||
</InputContainer>
|
</InputContainer>
|
||||||
<InputContainer label={'BGM'}>
|
<InputContainer label={'BGM'}>
|
||||||
<div className={style.tip}>
|
<div className={style.tip}>
|
||||||
接口上传体积有限制,上传文件请全力压缩到80k以下
|
接口上传体积有限制,上传文件请全力压缩到80k以下,推荐使用外链
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type={'file'}
|
type={'file'}
|
||||||
|
@ -400,7 +453,7 @@ const ConfigDialog: FC<{
|
||||||
</InputContainer>
|
</InputContainer>
|
||||||
<InputContainer label={'背景图'}>
|
<InputContainer label={'背景图'}>
|
||||||
<div className={style.tip}>
|
<div className={style.tip}>
|
||||||
接口上传体积有限制,上传文件请全力压缩到80k以下
|
接口上传体积有限制,上传的图片将会被严重压缩,推荐使用外链
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type={'file'}
|
type={'file'}
|
||||||
|
@ -431,36 +484,43 @@ const ConfigDialog: FC<{
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={'flex-container flex-center flex-no-wrap'}>
|
<div className={'flex-container flex-center flex-wrap'}>
|
||||||
<span>毛玻璃</span>
|
<div className={'flex-spacer flex-container flex-center'}>
|
||||||
<input
|
<span>毛玻璃</span>
|
||||||
type={'checkbox'}
|
<input
|
||||||
checked={!!customTheme.backgroundBlur}
|
type={'checkbox'}
|
||||||
onChange={(e) =>
|
checked={!!customTheme.backgroundBlur}
|
||||||
updateCustomTheme(
|
onChange={(e) =>
|
||||||
'backgroundBlur',
|
updateCustomTheme(
|
||||||
e.target.checked
|
'backgroundBlur',
|
||||||
)
|
e.target.checked
|
||||||
}
|
)
|
||||||
/>
|
}
|
||||||
<div className={'flex-spacer'} />
|
/>
|
||||||
<span>深色</span>
|
</div>
|
||||||
<input
|
<div className={'flex-spacer flex-container flex-center'}>
|
||||||
type={'checkbox'}
|
<span>深色</span>
|
||||||
checked={!!customTheme.dark}
|
<input
|
||||||
onChange={(e) =>
|
type={'checkbox'}
|
||||||
updateCustomTheme('dark', e.target.checked)
|
checked={!!customTheme.dark}
|
||||||
}
|
onChange={(e) =>
|
||||||
/>
|
updateCustomTheme('dark', e.target.checked)
|
||||||
<div className={'flex-spacer'} />
|
}
|
||||||
<span>纯色</span>
|
/>
|
||||||
<input
|
</div>
|
||||||
type={'color'}
|
<div className={'flex-spacer flex-container flex-center'}>
|
||||||
value={customTheme.backgroundColor || '#ffffff'}
|
<span>纯色</span>
|
||||||
onChange={(e) =>
|
<input
|
||||||
updateCustomTheme('backgroundColor', e.target.value)
|
type={'color'}
|
||||||
}
|
value={customTheme.backgroundColor || '#ffffff'}
|
||||||
/>
|
onChange={(e) =>
|
||||||
|
updateCustomTheme(
|
||||||
|
'backgroundColor',
|
||||||
|
e.target.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.tip}>
|
<div className={style.tip}>
|
||||||
使用图片或者纯色作为背景,图片可开启毛玻璃效果。如果你使用了深色的图片和颜色,请开启深色模式,此时标题等文字将变为亮色
|
使用图片或者纯色作为背景,图片可开启毛玻璃效果。如果你使用了深色的图片和颜色,请开启深色模式,此时标题等文字将变为亮色
|
||||||
|
@ -499,7 +559,7 @@ const ConfigDialog: FC<{
|
||||||
onChange={(e) => onNewSoundChange('name', e.target.value)}
|
onChange={(e) => onNewSoundChange('name', e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className={style.tip}>
|
<div className={style.tip}>
|
||||||
接口上传体积有限制,上传文件请全力压缩到10k以下
|
接口上传体积有限制,上传文件请全力压缩到10k以下,推荐使用外链
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type={'file'}
|
type={'file'}
|
||||||
|
@ -523,7 +583,7 @@ const ConfigDialog: FC<{
|
||||||
</InputContainer>
|
</InputContainer>
|
||||||
<InputContainer label={'图标素材'} required>
|
<InputContainer label={'图标素材'} required>
|
||||||
<div className={style.tip}>
|
<div className={style.tip}>
|
||||||
接口上传体积有限制,上传文件请全力压缩到5k以下,推荐尺寸56*56
|
接口上传体积有限制,上传的图片将会被严重压缩,推荐使用外链
|
||||||
</div>
|
</div>
|
||||||
</InputContainer>
|
</InputContainer>
|
||||||
{customTheme.icons.map((icon, idx) => (
|
{customTheme.icons.map((icon, idx) => (
|
||||||
|
@ -555,13 +615,14 @@ const ConfigDialog: FC<{
|
||||||
placeholder={'或者输入https外链'}
|
placeholder={'或者输入https外链'}
|
||||||
value={customTheme.icons[idx].content}
|
value={customTheme.icons[idx].content}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
if (!linkReg.test(e.target.value))
|
setIconErrors(
|
||||||
setIconErrors(
|
makeIconErrors(
|
||||||
makeIconErrors(
|
idx,
|
||||||
idx,
|
linkReg.test(e.target.value)
|
||||||
'请输入https外链'
|
? ''
|
||||||
)
|
: '请输入https外链'
|
||||||
);
|
)
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateIcons('content', e.target.value, idx)
|
updateIcons('content', e.target.value, idx)
|
||||||
|
@ -618,10 +679,23 @@ const ConfigDialog: FC<{
|
||||||
|
|
||||||
{genLink && (
|
{genLink && (
|
||||||
<div className={'flex-container flex-center flex-column'}>
|
<div className={'flex-container flex-center flex-column'}>
|
||||||
<QRCodeCanvas id="qrCode" value={genLink} size={300} />
|
<QRCodeCanvas
|
||||||
|
id="qrCode"
|
||||||
|
value={genLink}
|
||||||
|
size={300}
|
||||||
|
className={classNames(
|
||||||
|
style.qrCode,
|
||||||
|
uploading && style.uploading
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
captureElement('qrCode', customTheme.title)
|
captureElement(
|
||||||
|
'qrCode',
|
||||||
|
`${customTheme.title}-${localStorage.getItem(
|
||||||
|
LAST_CUSTOM_THEME_ID_STORAGE_KEY
|
||||||
|
)}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
className="primary"
|
className="primary"
|
||||||
>
|
>
|
||||||
|
@ -634,7 +708,7 @@ const ConfigDialog: FC<{
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={style.tip}>
|
<div className={style.tip}>
|
||||||
接口上传内容总体积有限制,上传文件失败请进一步压缩文件,或者使用外链(自行搜索【免费图床】【免费mp3外链】【对象存储服务】等关键词)。
|
接口上传内容总体积有限制,上传文件失败请尝试进一步压缩文件,推荐使用外链(自行搜索【免费图床】【免费mp3外链】【对象存储服务】等关键词)。
|
||||||
本地整活,勾选右侧关闭文件大小校验👉
|
本地整活,勾选右侧关闭文件大小校验👉
|
||||||
<input
|
<input
|
||||||
type={'checkbox'}
|
type={'checkbox'}
|
||||||
|
@ -643,10 +717,15 @@ const ConfigDialog: FC<{
|
||||||
setEnableFileSizeValidate(!e.target.checked)
|
setEnableFileSizeValidate(!e.target.checked)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
(谨慎操作,单文件不超过1M为宜,文件过大可能导致崩溃,介时请刷新浏览器,)
|
(谨慎操作,单文件不超过1M为宜,文件过大可能导致崩溃,介时请刷新浏览器)
|
||||||
</div>
|
</div>
|
||||||
{configError && <div className={style.errorTip}>{configError}</div>}
|
{configError && <div className={style.errorTip}>{configError}</div>}
|
||||||
<WxQrCode />
|
{customTheme.pure && (
|
||||||
|
<div className={style.tip}>
|
||||||
|
🎉🎉🎉恭喜发现彩蛋,生成的主题将开启纯净模式~
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<WxQrCode onClick={() => setPureClickTime(pureClickTime + 1)} />
|
||||||
<div className={'flex-container'}>
|
<div className={'flex-container'}>
|
||||||
<button
|
<button
|
||||||
className={'primary flex-grow'}
|
className={'primary flex-grow'}
|
||||||
|
@ -654,17 +733,17 @@ const ConfigDialog: FC<{
|
||||||
>
|
>
|
||||||
保存并预览
|
保存并预览
|
||||||
</button>
|
</button>
|
||||||
<button
|
{/*<button*/}
|
||||||
className={classNames(
|
{/* className={classNames(*/}
|
||||||
'primary flex-grow',
|
{/* 'primary flex-grow',*/}
|
||||||
style.uploadBtn,
|
{/* style.uploadBtn,*/}
|
||||||
uploading && style.uploading
|
{/* uploading && style.uploading*/}
|
||||||
)}
|
{/* )}*/}
|
||||||
onClick={onGenQrLinkClick}
|
{/* onClick={onGenQrLinkClick}*/}
|
||||||
>
|
{/* disabled*/}
|
||||||
{genLink ? '更新数据' : '生成二维码&链接'}
|
{/*>*/}
|
||||||
{uploading ? '...' : ''}
|
{/* 生成二维码&链接*/}
|
||||||
</button>
|
{/*</button>*/}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
133
src/components/Fireworks.module.scss
Normal file
133
src/components/Fireworks.module.scss
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
@use 'sass:math';
|
||||||
|
|
||||||
|
// 烟花动画,来源 https://codepen.io/yshlin/pen/WNMmQX
|
||||||
|
|
||||||
|
$particles: 50;
|
||||||
|
$width: 500;
|
||||||
|
$height: 500;
|
||||||
|
|
||||||
|
// Create the explosion...
|
||||||
|
$box-shadow: ();
|
||||||
|
$box-shadow2: ();
|
||||||
|
@for $i from 0 through $particles {
|
||||||
|
$box-shadow: $box-shadow,
|
||||||
|
math.random($width) -
|
||||||
|
math.div($width, 2) +
|
||||||
|
px
|
||||||
|
math.random($height) -
|
||||||
|
math.div($height, 1.2) +
|
||||||
|
px
|
||||||
|
hsl(math.random(360) 100% 50%);
|
||||||
|
$box-shadow2: $box-shadow2, 0 0 #fff;
|
||||||
|
}
|
||||||
|
@mixin keyframes($animationName) {
|
||||||
|
@keyframes #{$animationName} {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes #{$animationName} {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes #{$animationName} {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes #{$animationName} {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes #{$animationName} {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin animation-delay($settings) {
|
||||||
|
animation-delay: $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin animation-duration($settings) {
|
||||||
|
animation-duration: $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin animation($settings) {
|
||||||
|
animation: $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin transform($settings) {
|
||||||
|
transform: $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pyro {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pyro > .before,
|
||||||
|
.pyro > .after {
|
||||||
|
position: absolute;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: $box-shadow2;
|
||||||
|
@include animation(
|
||||||
|
(
|
||||||
|
1s bang ease-out infinite backwards,
|
||||||
|
1s gravity ease-in infinite backwards,
|
||||||
|
5s position linear infinite backwards
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pyro > .after {
|
||||||
|
@include animation-delay((1.25s, 1.25s, 1.25s));
|
||||||
|
@include animation-duration((1.25s, 1.25s, 6.25s));
|
||||||
|
}
|
||||||
|
|
||||||
|
@include keyframes(bang) {
|
||||||
|
to {
|
||||||
|
box-shadow: $box-shadow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include keyframes(gravity) {
|
||||||
|
to {
|
||||||
|
@include transform(translateY(200px));
|
||||||
|
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include keyframes(position) {
|
||||||
|
0%,
|
||||||
|
19.9% {
|
||||||
|
margin-top: 10%;
|
||||||
|
margin-left: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
20%,
|
||||||
|
39.9% {
|
||||||
|
margin-top: 40%;
|
||||||
|
margin-left: 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
40%,
|
||||||
|
59.9% {
|
||||||
|
margin-top: 20%;
|
||||||
|
margin-left: 70%;
|
||||||
|
}
|
||||||
|
|
||||||
|
60%,
|
||||||
|
79.9% {
|
||||||
|
margin-top: 30%;
|
||||||
|
margin-left: 20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
80%,
|
||||||
|
99.9% {
|
||||||
|
margin-top: 30%;
|
||||||
|
margin-left: 80%;
|
||||||
|
}
|
||||||
|
}
|
12
src/components/Fireworks.tsx
Normal file
12
src/components/Fireworks.tsx
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import style from './Fireworks.module.scss';
|
||||||
|
const Fireworks: FC = () => {
|
||||||
|
return (
|
||||||
|
<div className={style.pyro}>
|
||||||
|
<div className={style.before} />
|
||||||
|
<div className={style.after} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Fireworks;
|
|
@ -74,21 +74,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
|
||||||
position: fixed;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
background-color: rgb(0 0 0 / 10%);
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 10 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bgm-button {
|
.bgm-button {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 8px;
|
left: 8px;
|
||||||
|
|
|
@ -4,17 +4,20 @@ import React, {
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
Suspense,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import './Game.scss';
|
import './Game.scss';
|
||||||
import {
|
import {
|
||||||
LAST_LEVEL_STORAGE_KEY,
|
LAST_LEVEL_STORAGE_KEY,
|
||||||
LAST_SCORE_STORAGE_KEY,
|
LAST_SCORE_STORAGE_KEY,
|
||||||
LAST_TIME_STORAGE_KEY,
|
LAST_TIME_STORAGE_KEY,
|
||||||
linkReg,
|
|
||||||
randomString,
|
randomString,
|
||||||
|
resetScoreStorage,
|
||||||
|
timestampToUsedTimeString,
|
||||||
waitTimeout,
|
waitTimeout,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import { Icon, Theme } from '../themes/interface';
|
import { Icon, Theme } from '../themes/interface';
|
||||||
|
import Score from './Score';
|
||||||
|
|
||||||
interface MySymbol {
|
interface MySymbol {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -133,8 +136,9 @@ const Symbol: FC<SymbolProps> = ({ x, y, icon, isCover, status, onClick }) => {
|
||||||
style={{ opacity: isCover ? 0.4 : 1 }}
|
style={{ opacity: isCover ? 0.4 : 1 }}
|
||||||
>
|
>
|
||||||
{typeof icon.content === 'string' ? (
|
{typeof icon.content === 'string' ? (
|
||||||
linkReg.test(icon.content) ||
|
icon.content.startsWith('data:') ||
|
||||||
icon.content.startsWith('/') ? (
|
icon.content.startsWith('/') ||
|
||||||
|
icon.content.startsWith('http') ? (
|
||||||
/*图片地址*/
|
/*图片地址*/
|
||||||
<img src={icon.content} alt="" />
|
<img src={icon.content} alt="" />
|
||||||
) : (
|
) : (
|
||||||
|
@ -167,7 +171,7 @@ const Game: FC<{
|
||||||
Record<MySymbol['id'], number>
|
Record<MySymbol['id'], number>
|
||||||
>({});
|
>({});
|
||||||
const [finished, setFinished] = useState<boolean>(false);
|
const [finished, setFinished] = useState<boolean>(false);
|
||||||
const [tipText, setTipText] = useState<string>('');
|
const [success, setSuccess] = useState<boolean>(false);
|
||||||
const [animating, setAnimating] = useState<boolean>(false);
|
const [animating, setAnimating] = useState<boolean>(false);
|
||||||
|
|
||||||
// 音效
|
// 音效
|
||||||
|
@ -323,6 +327,7 @@ const Game: FC<{
|
||||||
// 重开
|
// 重开
|
||||||
const restart = () => {
|
const restart = () => {
|
||||||
setFinished(false);
|
setFinished(false);
|
||||||
|
setSuccess(false);
|
||||||
setScore(0);
|
setScore(0);
|
||||||
setLevel(1);
|
setLevel(1);
|
||||||
setQueue([]);
|
setQueue([]);
|
||||||
|
@ -391,24 +396,26 @@ const Game: FC<{
|
||||||
|
|
||||||
// 输了
|
// 输了
|
||||||
if (updateQueue.length === 7) {
|
if (updateQueue.length === 7) {
|
||||||
setTipText('失败了');
|
|
||||||
setFinished(true);
|
setFinished(true);
|
||||||
|
setSuccess(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!updateScene.find((s) => s.status !== 2)) {
|
if (!updateScene.find((s) => s.status !== 2)) {
|
||||||
// 胜利
|
// 队列清空了
|
||||||
if (level === maxLevel) {
|
if (level === maxLevel) {
|
||||||
setTipText('完成挑战');
|
// 胜利
|
||||||
setFinished(true);
|
setFinished(true);
|
||||||
return;
|
setSuccess(true);
|
||||||
|
} else {
|
||||||
|
// 升级
|
||||||
|
// 通关奖励关卡对应数值分数
|
||||||
|
setScore(score + level);
|
||||||
|
setLevel(level + 1);
|
||||||
|
setQueue([]);
|
||||||
|
checkCover(makeScene(level + 1, theme.icons));
|
||||||
}
|
}
|
||||||
// 升级
|
|
||||||
// 通关奖励关卡对应数值分数
|
|
||||||
setScore(score + level);
|
|
||||||
setLevel(level + 1);
|
|
||||||
setQueue([]);
|
|
||||||
checkCover(makeScene(level + 1, theme.icons));
|
|
||||||
} else {
|
} else {
|
||||||
|
// 更新队列
|
||||||
setQueue(updateQueue);
|
setQueue(updateQueue);
|
||||||
checkCover(updateScene);
|
checkCover(updateScene);
|
||||||
}
|
}
|
||||||
|
@ -425,9 +432,7 @@ const Game: FC<{
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (finished) {
|
if (finished) {
|
||||||
intervalRef.current && clearInterval(intervalRef.current);
|
intervalRef.current && clearInterval(intervalRef.current);
|
||||||
localStorage.setItem(LAST_LEVEL_STORAGE_KEY, '1');
|
resetScoreStorage();
|
||||||
localStorage.setItem(LAST_SCORE_STORAGE_KEY, '0');
|
|
||||||
localStorage.setItem(LAST_TIME_STORAGE_KEY, '0');
|
|
||||||
}
|
}
|
||||||
}, [finished]);
|
}, [finished]);
|
||||||
// 更新使用时间
|
// 更新使用时间
|
||||||
|
@ -488,17 +493,21 @@ const Game: FC<{
|
||||||
<br />
|
<br />
|
||||||
得分{score}
|
得分{score}
|
||||||
<br />
|
<br />
|
||||||
用时{(usedTime / 1000).toFixed(3)}秒
|
用时{timestampToUsedTimeString(usedTime)}
|
||||||
</div>
|
</div>
|
||||||
{/*提示弹窗*/}
|
{/*积分、排行榜*/}
|
||||||
{finished && (
|
<Suspense fallback={<span>rank list</span>}>
|
||||||
<div className="modal">
|
{finished && (
|
||||||
<h1>{tipText}</h1>
|
<Score
|
||||||
<h1>得分{score}</h1>
|
level={level}
|
||||||
<h1>用时{(usedTime / 1000).toFixed(3)}秒</h1>
|
time={usedTime}
|
||||||
<button onClick={restart}>再来一次</button>
|
score={score}
|
||||||
</div>
|
success={success}
|
||||||
)}
|
pure={theme.pure}
|
||||||
|
restartMethod={restart}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Suspense>
|
||||||
{/*bgm*/}
|
{/*bgm*/}
|
||||||
{theme.bgm && (
|
{theme.bgm && (
|
||||||
<button className="bgm-button" onClick={() => setBgmOn(!bgmOn)}>
|
<button className="bgm-button" onClick={() => setBgmOn(!bgmOn)}>
|
||||||
|
|
89
src/components/Score.module.scss
Normal file
89
src/components/Score.module.scss
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
background-color: rgb(0 0 0 / 20%);
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 10 !important;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
max-width: 450px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0 auto;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameInput {
|
||||||
|
background-color: rgb(0 0 0 / 20%);
|
||||||
|
color: currentcolor;
|
||||||
|
border: 1px currentcolor dashed;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: currentcolor;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rankContainer {
|
||||||
|
border: 1px solid currentcolor;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-height: 200px;
|
||||||
|
background-color: rgb(0 0 0 / 20%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: 900;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 30vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
thead {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background-color: gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.username {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
font-size: 18px;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.8;
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
color: crimson;
|
||||||
|
}
|
||||||
|
}
|
297
src/components/Score.tsx
Normal file
297
src/components/Score.tsx
Normal file
|
@ -0,0 +1,297 @@
|
||||||
|
import React, { FC, Suspense, useEffect, useRef, useState } from 'react';
|
||||||
|
import style from './Score.module.scss';
|
||||||
|
import Bmob from 'hydrogen-js-sdk';
|
||||||
|
import {
|
||||||
|
PLAYING_THEME_ID_STORAGE_KEY,
|
||||||
|
randomString,
|
||||||
|
timestampToUsedTimeString,
|
||||||
|
USER_ID_STORAGE_KEY,
|
||||||
|
USER_NAME_STORAGE_KEY,
|
||||||
|
} from '../utils';
|
||||||
|
import WxQrCode from './WxQrCode';
|
||||||
|
|
||||||
|
const Fireworks = React.lazy(() => import('./Fireworks'));
|
||||||
|
|
||||||
|
interface RankInfo {
|
||||||
|
// id
|
||||||
|
objectId?: string;
|
||||||
|
// 综合评分
|
||||||
|
rating: number;
|
||||||
|
// 通关数
|
||||||
|
level: number;
|
||||||
|
// 游戏得分
|
||||||
|
score: number;
|
||||||
|
// 主题id
|
||||||
|
themeId: string;
|
||||||
|
// 耗时
|
||||||
|
time: number;
|
||||||
|
// 用户昵称
|
||||||
|
username: string;
|
||||||
|
// 用户id
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 该组件条件渲染
|
||||||
|
const Score: FC<{
|
||||||
|
level: number;
|
||||||
|
score: number;
|
||||||
|
time: number;
|
||||||
|
success: boolean;
|
||||||
|
pure?: boolean;
|
||||||
|
restartMethod: () => void;
|
||||||
|
}> = ({ level, score, time, success, restartMethod, pure = false }) => {
|
||||||
|
const [rankList, setRankList] = useState<RankInfo[]>([]);
|
||||||
|
const [username, setUsername] = useState<string>(
|
||||||
|
localStorage.getItem(USER_NAME_STORAGE_KEY) || ''
|
||||||
|
);
|
||||||
|
const [userId, setUserId] = useState<string>(
|
||||||
|
localStorage.getItem(USER_ID_STORAGE_KEY) || ''
|
||||||
|
);
|
||||||
|
const usernameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [tip, setTip] = useState<string>('');
|
||||||
|
|
||||||
|
// 综合评分
|
||||||
|
const rating = Math.max(0, score) * 100 - Math.round(time / 1000);
|
||||||
|
// 分主题排行
|
||||||
|
const themeId = localStorage.getItem(PLAYING_THEME_ID_STORAGE_KEY);
|
||||||
|
|
||||||
|
const uploadRankInfo = (id?: string) => {
|
||||||
|
const _userId = localStorage.getItem(USER_ID_STORAGE_KEY);
|
||||||
|
const _username = localStorage.getItem(USER_NAME_STORAGE_KEY);
|
||||||
|
if (!themeId || !_userId || !_username) return;
|
||||||
|
const rankInfo: RankInfo = {
|
||||||
|
rating,
|
||||||
|
themeId,
|
||||||
|
level,
|
||||||
|
score,
|
||||||
|
time,
|
||||||
|
username: _username,
|
||||||
|
userId: _userId,
|
||||||
|
};
|
||||||
|
const query = Bmob.Query('rank');
|
||||||
|
id && query.set('id', id);
|
||||||
|
for (const [key, val] of Object.entries(rankInfo)) {
|
||||||
|
query.set(key, val);
|
||||||
|
}
|
||||||
|
query
|
||||||
|
.save()
|
||||||
|
.then(() => {
|
||||||
|
getRankList();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.log(e);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRankList = (cb?: (rankList: RankInfo[]) => void) => {
|
||||||
|
if (!themeId) return;
|
||||||
|
const query = Bmob.Query('rank');
|
||||||
|
query.equalTo('themeId', '==', themeId);
|
||||||
|
query.order('-rating');
|
||||||
|
query.limit(50);
|
||||||
|
query
|
||||||
|
.find()
|
||||||
|
.then((res) => {
|
||||||
|
setRankList(res as any);
|
||||||
|
cb && cb(res as any);
|
||||||
|
const _userId = localStorage.getItem(USER_ID_STORAGE_KEY);
|
||||||
|
if (_userId) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const rankEl = document.getElementById(_userId + 'el');
|
||||||
|
rankEl?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.log(e);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onConfirmNameClick = () => {
|
||||||
|
const inputUsername = usernameInputRef.current?.value.trim();
|
||||||
|
if (!inputUsername) return;
|
||||||
|
const newUserId = randomString(8);
|
||||||
|
setUsername(inputUsername);
|
||||||
|
setUserId(newUserId);
|
||||||
|
localStorage.setItem(USER_NAME_STORAGE_KEY, inputUsername);
|
||||||
|
localStorage.setItem(USER_ID_STORAGE_KEY, newUserId);
|
||||||
|
judgeAndUpload(rankList, newUserId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 判断是否需要上传记录
|
||||||
|
const judgeAndUpload = (_rankList: RankInfo[], _userId: string) => {
|
||||||
|
if (!_userId) return;
|
||||||
|
if (
|
||||||
|
_rankList.length < 50 ||
|
||||||
|
rating > _rankList[_rankList.length - 1].rating
|
||||||
|
) {
|
||||||
|
// 榜未满或者分数高于榜上最后一名
|
||||||
|
// 本次排名
|
||||||
|
let thisRank = _rankList.findIndex((rank) => rank.rating < rating);
|
||||||
|
if (thisRank === -1) {
|
||||||
|
thisRank = _rankList.length + 1;
|
||||||
|
} else {
|
||||||
|
thisRank++;
|
||||||
|
}
|
||||||
|
// 查找是否曾上榜
|
||||||
|
const findSelf = _rankList.findIndex(
|
||||||
|
(rank) => rank.userId === _userId
|
||||||
|
);
|
||||||
|
if (findSelf === -1) {
|
||||||
|
// 新上榜
|
||||||
|
uploadRankInfo();
|
||||||
|
setTip(`恭喜上榜!本次排名${thisRank}`);
|
||||||
|
} else {
|
||||||
|
if (_rankList[findSelf].rating < rating) {
|
||||||
|
// 破自己记录
|
||||||
|
uploadRankInfo(_rankList[findSelf].objectId);
|
||||||
|
setTip(`个人新高!本次排名${thisRank}`);
|
||||||
|
} else if (_rankList[findSelf].rating > rating) {
|
||||||
|
// 没破自己记录
|
||||||
|
setTip(
|
||||||
|
`距离你的最高记录${_rankList[findSelf].rating}还差一点~`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setTip(`与你的最高记录${_rankList[findSelf].rating}持平~`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 未上榜
|
||||||
|
setTip('本次未上榜');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!__DIY__) {
|
||||||
|
// 排行榜
|
||||||
|
getRankList((rankList) =>
|
||||||
|
judgeAndUpload(
|
||||||
|
rankList,
|
||||||
|
localStorage.getItem(USER_ID_STORAGE_KEY) || ''
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.modal}>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<span style={{ position: 'absolute' }}>🎆fireworks🎆</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{success && <Fireworks />}
|
||||||
|
</Suspense>
|
||||||
|
<div className={style.inner}>
|
||||||
|
{success ? <h1>🎉恭喜通关!</h1> : <h1>😫就差一点!</h1>}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>通关数</th>
|
||||||
|
<th>用时</th>
|
||||||
|
<th>得分</th>
|
||||||
|
<th>综合评分</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>{level}</td>
|
||||||
|
<td>{timestampToUsedTimeString(time)}</td>
|
||||||
|
<td>{score}</td>
|
||||||
|
<td>{rating}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{!__DIY__ && !username && (
|
||||||
|
<div className={'flex-container flex-center'}>
|
||||||
|
<input
|
||||||
|
className={style.nameInput}
|
||||||
|
ref={usernameInputRef}
|
||||||
|
maxLength={12}
|
||||||
|
placeholder={'留下大名进行排行榜pk!'}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={'primary'}
|
||||||
|
onClick={onConfirmNameClick}
|
||||||
|
>
|
||||||
|
确定
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tip && <div>{tip}</div>}
|
||||||
|
|
||||||
|
{__DIY__ && (
|
||||||
|
<button className={'primary'} onClick={restartMethod}>
|
||||||
|
再来一次
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!__DIY__ && (
|
||||||
|
<div className={style.rankContainer}>
|
||||||
|
<h1 className={style.title}>TOP 50</h1>
|
||||||
|
{rankList.length ? (
|
||||||
|
<div className={style.list}>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>名次</th>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>通关数</th>
|
||||||
|
{/*<th>用时</th>*/}
|
||||||
|
{/*<th>得分</th>*/}
|
||||||
|
<th>综合评分</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rankList.map((rank, idx) => (
|
||||||
|
<tr
|
||||||
|
key={idx}
|
||||||
|
id={rank.userId + 'el'}
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
rank.userId === userId
|
||||||
|
? 'rgb(0 0 0 / 20%)'
|
||||||
|
: '',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td>{idx + 1}</td>
|
||||||
|
<td className={style.username}>
|
||||||
|
{rank.username}
|
||||||
|
{rank.userId === userId &&
|
||||||
|
'(你)'}
|
||||||
|
</td>
|
||||||
|
<td>{rank.level}</td>
|
||||||
|
{/*<td>*/}
|
||||||
|
{/* {timestampToUsedTimeString(*/}
|
||||||
|
{/* rank.time*/}
|
||||||
|
{/* )}*/}
|
||||||
|
{/*</td>*/}
|
||||||
|
{/*<td>{rank.score}</td>*/}
|
||||||
|
<td>{rank.rating}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={style.tip}>
|
||||||
|
暂无排行,速速霸榜!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button className={'primary'} onClick={restartMethod}>
|
||||||
|
再来一次
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!pure && <WxQrCode />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Score;
|
|
@ -1,8 +1,9 @@
|
||||||
import React, { FC, useState } from 'react';
|
import React, { FC, MouseEventHandler, useState } from 'react';
|
||||||
import style from './WxQrCode.module.scss';
|
import style from './WxQrCode.module.scss';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
const WxQrCode: FC<{ title?: string }> = ({
|
const WxQrCode: FC<{ title?: string; onClick?: MouseEventHandler }> = ({
|
||||||
title = '【广告位招租中】同时如果您喜欢这个项目的话,可以点击扫描下方收款码分摊后台相关费用,感谢~😘',
|
title = '如果您喜欢这个项目的话,点击扫描下方收款码请我喝杯咖啡,感谢~😘',
|
||||||
|
onClick,
|
||||||
}) => {
|
}) => {
|
||||||
const [fullScreen, setFullScreen] = useState<Record<number, boolean>>({
|
const [fullScreen, setFullScreen] = useState<Record<number, boolean>>({
|
||||||
0: false,
|
0: false,
|
||||||
|
@ -28,7 +29,7 @@ const WxQrCode: FC<{ title?: string }> = ({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className={style.wxQrCodeContainer}>
|
<div className={style.wxQrCodeContainer} onClick={onClick}>
|
||||||
<div className={style.wxQrCodeTitle}>{title}</div>
|
<div className={style.wxQrCodeTitle}>{title}</div>
|
||||||
{[1, 5, 8].map((num, idx) => (
|
{[1, 5, 8].map((num, idx) => (
|
||||||
<div
|
<div
|
||||||
|
@ -41,7 +42,7 @@ const WxQrCode: FC<{ title?: string }> = ({
|
||||||
<span className={style.wxQrCodeItemTitle}>¥ {num}</span>
|
<span className={style.wxQrCodeItemTitle}>¥ {num}</span>
|
||||||
<img
|
<img
|
||||||
alt={''}
|
alt={''}
|
||||||
src={`/wxQrCode${num}.jpg`}
|
src={`/wxQrcode${num}.jpg`}
|
||||||
className={style.wxQrCodeItemImage}
|
className={style.wxQrCodeItemImage}
|
||||||
onClick={() => onImageClick(idx)}
|
onClick={() => onImageClick(idx)}
|
||||||
/>
|
/>
|
||||||
|
|
93
src/main.tsx
93
src/main.tsx
|
@ -5,9 +5,10 @@ import './styles/global.scss';
|
||||||
import './styles/utils.scss';
|
import './styles/utils.scss';
|
||||||
import Bmob from 'hydrogen-js-sdk';
|
import Bmob from 'hydrogen-js-sdk';
|
||||||
import {
|
import {
|
||||||
DEFAULT_BGM_STORAGE_KEY,
|
|
||||||
domRelatedOptForTheme,
|
domRelatedOptForTheme,
|
||||||
parsePathCustomThemeId,
|
parsePathCustomThemeId,
|
||||||
|
PLAYING_THEME_ID_STORAGE_KEY,
|
||||||
|
resetScoreStorage,
|
||||||
wrapThemeDefaultSounds,
|
wrapThemeDefaultSounds,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import { getDefaultTheme } from './themes/default';
|
import { getDefaultTheme } from './themes/default';
|
||||||
|
@ -33,6 +34,21 @@ const errorTip = (tip: string) => {
|
||||||
|
|
||||||
// 加载成功后数据转换(runtime)以及转场
|
// 加载成功后数据转换(runtime)以及转场
|
||||||
const successTrans = (theme: Theme<any>) => {
|
const successTrans = (theme: Theme<any>) => {
|
||||||
|
// 如果上次玩的不是这个主题,清除缓存分数
|
||||||
|
const lastPlayTheme = localStorage.getItem(PLAYING_THEME_ID_STORAGE_KEY);
|
||||||
|
if (
|
||||||
|
!lastPlayTheme ||
|
||||||
|
![customThemeIdFromPath, theme.title].includes(lastPlayTheme)
|
||||||
|
) {
|
||||||
|
resetScoreStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存当前主题id
|
||||||
|
localStorage.setItem(
|
||||||
|
PLAYING_THEME_ID_STORAGE_KEY,
|
||||||
|
customThemeIdFromPath || theme.title
|
||||||
|
);
|
||||||
|
|
||||||
wrapThemeDefaultSounds(theme);
|
wrapThemeDefaultSounds(theme);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
@ -57,54 +73,37 @@ Bmob.initialize(
|
||||||
import.meta.env.VITE_BMOB_SECCODE
|
import.meta.env.VITE_BMOB_SECCODE
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadTheme = () => {
|
// 请求主题
|
||||||
// 请求主题
|
if (customThemeIdFromPath) {
|
||||||
if (customThemeIdFromPath) {
|
const storageTheme = localStorage.getItem(customThemeIdFromPath);
|
||||||
const storageTheme = localStorage.getItem(customThemeIdFromPath);
|
if (storageTheme) {
|
||||||
if (storageTheme) {
|
try {
|
||||||
try {
|
const customTheme = JSON.parse(storageTheme);
|
||||||
const customTheme = JSON.parse(storageTheme);
|
successTrans(customTheme);
|
||||||
successTrans(customTheme);
|
} catch (e) {
|
||||||
} catch (e) {
|
errorTip('主题配置解析失败');
|
||||||
errorTip('主题配置解析失败');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Bmob.Query('config')
|
|
||||||
.get(customThemeIdFromPath)
|
|
||||||
.then((res) => {
|
|
||||||
const { content } = res as any;
|
|
||||||
localStorage.setItem(customThemeIdFromPath, content);
|
|
||||||
try {
|
|
||||||
const customTheme = JSON.parse(content);
|
|
||||||
successTrans(customTheme);
|
|
||||||
} catch (e) {
|
|
||||||
errorTip('主题配置解析失败');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(({ error }) => {
|
|
||||||
errorTip(error);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
successTrans(getDefaultTheme());
|
Bmob.Query('config')
|
||||||
|
.get(customThemeIdFromPath)
|
||||||
|
.then((res) => {
|
||||||
|
const { content, increment } = res as any;
|
||||||
|
localStorage.setItem(customThemeIdFromPath, content);
|
||||||
|
try {
|
||||||
|
const customTheme = JSON.parse(content);
|
||||||
|
successTrans(customTheme);
|
||||||
|
} catch (e) {
|
||||||
|
errorTip('主题配置解析失败');
|
||||||
|
}
|
||||||
|
// 统计访问次数
|
||||||
|
increment('visitNum');
|
||||||
|
// @ts-ignore
|
||||||
|
res.save();
|
||||||
|
})
|
||||||
|
.catch(({ error }) => {
|
||||||
|
errorTip(error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// 音效资源请求
|
|
||||||
if (!localStorage.getItem(DEFAULT_BGM_STORAGE_KEY)) {
|
|
||||||
const query = Bmob.Query('file');
|
|
||||||
query.equalTo('type', '==', 'default');
|
|
||||||
query
|
|
||||||
.find()
|
|
||||||
.then((results) => {
|
|
||||||
for (const file of results as any) {
|
|
||||||
localStorage.setItem(file.name, file.base64);
|
|
||||||
}
|
|
||||||
loadTheme();
|
|
||||||
})
|
|
||||||
.catch(({ error }) => {
|
|
||||||
errorTip(error);
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
loadTheme();
|
successTrans(getDefaultTheme());
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,4 @@
|
||||||
import { Theme } from '../interface';
|
import { Theme } from '../interface';
|
||||||
import {
|
|
||||||
DEFAULT_BGM_STORAGE_KEY,
|
|
||||||
DEFAULT_CLICK_SOUND_STORAGE_KEY,
|
|
||||||
DEFAULT_TRIPLE_SOUND_STORAGE_KEY,
|
|
||||||
} from '../../utils';
|
|
||||||
|
|
||||||
const icons = <const>[
|
const icons = <const>[
|
||||||
`🎨`,
|
`🎨`,
|
||||||
|
@ -25,6 +20,7 @@ export const getDefaultTheme: () => Theme<DefaultSoundNames> = () => {
|
||||||
title: '有解的羊了个羊',
|
title: '有解的羊了个羊',
|
||||||
desc: '真的可以通关~',
|
desc: '真的可以通关~',
|
||||||
dark: true,
|
dark: true,
|
||||||
|
maxLevel: 20,
|
||||||
backgroundColor: '#8dac85',
|
backgroundColor: '#8dac85',
|
||||||
icons: icons.map((icon) => ({
|
icons: icons.map((icon) => ({
|
||||||
name: icon,
|
name: icon,
|
||||||
|
@ -35,16 +31,13 @@ export const getDefaultTheme: () => Theme<DefaultSoundNames> = () => {
|
||||||
sounds: [
|
sounds: [
|
||||||
{
|
{
|
||||||
name: 'button-click',
|
name: 'button-click',
|
||||||
src:
|
src: 'https://minio.streakingman.com/solvable-sheep-game/sound-button-click.mp3',
|
||||||
localStorage.getItem(DEFAULT_CLICK_SOUND_STORAGE_KEY) || '',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'triple',
|
name: 'triple',
|
||||||
src:
|
src: 'https://minio.streakingman.com/solvable-sheep-game/sound-triple.mp3',
|
||||||
localStorage.getItem(DEFAULT_TRIPLE_SOUND_STORAGE_KEY) ||
|
|
||||||
'',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bgm: localStorage.getItem(DEFAULT_BGM_STORAGE_KEY) || '',
|
bgm: 'https://minio.streakingman.com/solvable-sheep-game/sound-disco.mp3',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
139
src/utils.ts
139
src/utils.ts
|
@ -1,18 +1,26 @@
|
||||||
import { Theme } from './themes/interface';
|
import { Theme } from './themes/interface';
|
||||||
import { getDefaultTheme } from './themes/default';
|
import { getDefaultTheme } from './themes/default';
|
||||||
|
|
||||||
|
// local
|
||||||
export const LAST_LEVEL_STORAGE_KEY = 'lastLevel';
|
export const LAST_LEVEL_STORAGE_KEY = 'lastLevel';
|
||||||
export const LAST_SCORE_STORAGE_KEY = 'lastScore';
|
export const LAST_SCORE_STORAGE_KEY = 'lastScore';
|
||||||
export const LAST_TIME_STORAGE_KEY = 'lastTime';
|
export const LAST_TIME_STORAGE_KEY = 'lastTime';
|
||||||
export const CUSTOM_THEME_ID_STORAGE_KEY = 'customThemeId';
|
export const LAST_CUSTOM_THEME_ID_STORAGE_KEY = 'lastCustomThemeId';
|
||||||
|
export const LAST_UPLOAD_TIME_STORAGE_KEY = 'lastUploadTime';
|
||||||
export const CUSTOM_THEME_STORAGE_KEY = 'customTheme';
|
export const CUSTOM_THEME_STORAGE_KEY = 'customTheme';
|
||||||
export const CUSTOM_THEME_FILE_VALIDATE_STORAGE_KEY = 'customThemeFileValidate';
|
export const CUSTOM_THEME_FILE_VALIDATE_STORAGE_KEY = 'customThemeFileValidate';
|
||||||
export const DEFAULT_BGM_STORAGE_KEY = 'defaultBgm';
|
export const USER_NAME_STORAGE_KEY = 'username';
|
||||||
export const DEFAULT_TRIPLE_SOUND_STORAGE_KEY = 'defaultTripleSound';
|
export const USER_ID_STORAGE_KEY = 'userId';
|
||||||
export const DEFAULT_CLICK_SOUND_STORAGE_KEY = 'defaultClickSound';
|
export const PLAYING_THEME_ID_STORAGE_KEY = 'playingThemeId';
|
||||||
|
|
||||||
export const linkReg = /^(https|data):+/;
|
export const linkReg = /^(https|data):+/;
|
||||||
|
|
||||||
|
export const resetScoreStorage = () => {
|
||||||
|
localStorage.setItem(LAST_LEVEL_STORAGE_KEY, '1');
|
||||||
|
localStorage.setItem(LAST_SCORE_STORAGE_KEY, '0');
|
||||||
|
localStorage.setItem(LAST_TIME_STORAGE_KEY, '0');
|
||||||
|
};
|
||||||
|
|
||||||
export const randomString: (len: number) => string = (len) => {
|
export const randomString: (len: number) => string = (len) => {
|
||||||
const pool = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
const pool = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
let res = '';
|
let res = '';
|
||||||
|
@ -141,3 +149,126 @@ export const getFileBase64String: (file: File) => Promise<string> = (
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const timestampToUsedTimeString: (time: number) => string = (time) => {
|
||||||
|
try {
|
||||||
|
const hours = Math.floor(time / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor(
|
||||||
|
(time - 1000 * 60 * 60 * hours) / (1000 * 60)
|
||||||
|
);
|
||||||
|
const seconds = (
|
||||||
|
(time - 1000 * 60 * 60 * hours - 1000 * 60 * minutes) /
|
||||||
|
1000
|
||||||
|
).toFixed(3);
|
||||||
|
if (hours) {
|
||||||
|
return `${hours}小时${minutes}分${seconds}秒`;
|
||||||
|
} else if (minutes) {
|
||||||
|
return `${minutes}分${seconds}秒`;
|
||||||
|
} else {
|
||||||
|
return `${seconds}秒`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return '时间转换出错';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dataURLToFile: (dataURL: string, filename?: string) => File = (
|
||||||
|
// #endregion dataURLToFile
|
||||||
|
dataURL,
|
||||||
|
filename
|
||||||
|
) => {
|
||||||
|
const isDataURL = dataURL.startsWith('data:');
|
||||||
|
if (!isDataURL) throw new Error('Data URL 错误');
|
||||||
|
const _fileName = filename || new Date().getTime().toString();
|
||||||
|
const mimeType = dataURL.match(/^data:([^;]+);/)?.[1] || '';
|
||||||
|
// base64转二进制
|
||||||
|
const binaryString = atob(dataURL.split(',')[1]);
|
||||||
|
let binaryStringLength = binaryString.length;
|
||||||
|
const bytes = new Uint8Array(binaryStringLength);
|
||||||
|
while (binaryStringLength--) {
|
||||||
|
bytes[binaryStringLength] = binaryString.charCodeAt(binaryStringLength);
|
||||||
|
}
|
||||||
|
return new File([bytes], _fileName, { type: mimeType });
|
||||||
|
};
|
||||||
|
|
||||||
|
interface drawImgSrcInCanvasParams {
|
||||||
|
imgSrc: string;
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
scale?: number;
|
||||||
|
}
|
||||||
|
export const drawImgSrcInCanvas: (
|
||||||
|
params: drawImgSrcInCanvasParams
|
||||||
|
) => Promise<void> = async ({ imgSrc, canvas, scale = 1 }) => {
|
||||||
|
if (scale < 0) throw new Error('scale不能小于0');
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx || !(ctx instanceof CanvasRenderingContext2D)) {
|
||||||
|
throw new Error('Failed to get 2D context');
|
||||||
|
}
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.setAttribute('src', imgSrc);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
img.onload = () => {
|
||||||
|
const width = img.width * scale;
|
||||||
|
const height = img.height * scale;
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
reject(new Error('图片加载失败'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
interface createCanvasByImgSrcParams {
|
||||||
|
imgSrc: HTMLImageElement['src'];
|
||||||
|
scale?: number;
|
||||||
|
}
|
||||||
|
export const createCanvasByImgSrc: (
|
||||||
|
params: createCanvasByImgSrcParams
|
||||||
|
) => Promise<HTMLCanvasElement> = async ({ imgSrc, scale = 1 }) => {
|
||||||
|
if (scale < 0) throw new Error('scale不能小于0');
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
await drawImgSrcInCanvas({
|
||||||
|
imgSrc,
|
||||||
|
canvas,
|
||||||
|
scale,
|
||||||
|
});
|
||||||
|
return canvas;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CanvasToFileParams {
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
fileName?: string;
|
||||||
|
maxFileSize?: number;
|
||||||
|
}
|
||||||
|
export const canvasToFile: (
|
||||||
|
params: CanvasToFileParams
|
||||||
|
) => Promise<File> = async ({ canvas, fileName, maxFileSize }) => {
|
||||||
|
// #endregion canvasToFile
|
||||||
|
const MIME_TYPE = 'image/png';
|
||||||
|
const dataURL = canvas.toDataURL(MIME_TYPE);
|
||||||
|
const _fileName = fileName || new Date().getTime().toString();
|
||||||
|
const genFile = dataURLToFile(dataURL, _fileName);
|
||||||
|
// 判断是否需要压缩
|
||||||
|
if (maxFileSize && genFile.size > maxFileSize) {
|
||||||
|
let scale = Math.sqrt(maxFileSize / genFile.size);
|
||||||
|
if (scale > 0.9) scale = 0.9;
|
||||||
|
// TODO 暂时通过canvas绘制缩放图像进行递归压缩,后续考虑其他方式
|
||||||
|
// TODO 不断生成canvas, 调研内存是否会泄漏
|
||||||
|
const _canvas = await createCanvasByImgSrc({
|
||||||
|
imgSrc: dataURL,
|
||||||
|
scale,
|
||||||
|
});
|
||||||
|
return canvasToFile({
|
||||||
|
canvas: _canvas,
|
||||||
|
fileName: _fileName,
|
||||||
|
maxFileSize,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return genFile;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
BIN
wxQrCodes.png
Normal file
BIN
wxQrCodes.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 138 KiB |
Loading…
Reference in New Issue
Block a user