40 Commits

Author SHA1 Message Date
23d0b08242 chore: 更新版本信息(V3.1.2) 2024-10-13 22:06:04 +08:00
744c054c1c feat(工具箱): 启用“原神时钟”项 2024-10-13 18:49:43 +08:00
2a76490656 feat(工具箱): 完善“原神时钟”,添加背景图片 2024-10-13 18:49:26 +08:00
53299b83b3 feat(工具箱): 完善“原神时钟”的交互逻辑 2024-10-13 17:25:05 +08:00
865ef1e383 chore(工具箱): 调整页面路由生成逻辑,不跳过未启用的工具 2024-10-13 12:11:02 +08:00
0310eee39e fix(工具箱): 优化“保持亮屏”工具界面样式,背景添加圆角 2024-10-13 12:07:57 +08:00
fcda974626 feat(工具箱): 完善“原神时钟”,添加时间信息和提示文本 2024-10-13 12:00:08 +08:00
81b714333a feat(工具箱): 完善“原神时钟”,使用 CSS 计算实现图标高亮效果 2024-10-13 00:33:03 +08:00
6516fe4905 feat(工具箱): 完善“原神时钟”,添加动画效果,支持旋转指针 2024-10-12 22:41:52 +08:00
e37d12a5f2 chore(app): 添加 svg-arc.js 2024-10-12 22:41:12 +08:00
331e037714 feat(工具箱): 添加“原神时钟”工具 2024-10-12 22:41:08 +08:00
ef95e5ce73 feat(工具箱): 添加“保持亮屏”工具 2024-10-11 00:12:11 +08:00
75f9985265 chore: 更新版本信息(V3.1.1) 2024-09-08 22:54:13 +08:00
95d1352a2b chore: 优化更新日志内容 2024-09-08 22:53:59 +08:00
0c9091f5e6 build: 更新 @vitejs/plugin-legacy 插件配置,关闭 polyfills 2024-09-08 22:53:35 +08:00
0a86b75454 build: 更新 esbuild 配置,解决“Big integer literals”报错 2024-09-08 22:53:25 +08:00
b1c572903f chore: 更新版本信息(V3.1.0) 2024-09-08 22:25:07 +08:00
521edd2a8f feat(工具箱): 实现“生成随机字符串”工具 2024-09-08 21:57:09 +08:00
96425f7e70 fix(工具箱): 优化表单内容布局 2024-09-08 21:31:36 +08:00
de3cd7bb34 feat(网址导航): 记录最后一次选中的链接分类 2024-09-08 21:01:30 +08:00
1c87705166 feat(工具箱): 实现“比例计算”工具 2024-09-08 20:42:26 +08:00
d2eb0fa284 chore(app): 安装 Math.js 2024-09-08 20:41:57 +08:00
4b1d0d2139 fix(工具箱): 解决打包后工具页面无法显示的问题 2024-09-08 16:59:41 +08:00
bcd8fc3963 feat(app): 启用工具箱模块 2024-09-08 16:29:14 +08:00
b852159e39 feat(工具箱): 完善工具箱页面,添加返回按钮和工具版本信息 2024-09-08 16:27:42 +08:00
a31ad98a9e feat(工具箱): 实现“下载用时计算”工具 2024-09-08 16:06:31 +08:00
10ab2fb670 chore(app): 引入 Day.js 2024-09-08 16:04:20 +08:00
0d30d6cb5a chore(app): 安装 Day.js 2024-09-08 16:04:13 +08:00
05f3fbc454 feat(工具箱): 处理工具箱分类“enabled”属性 2024-09-08 15:08:15 +08:00
2365231986 chore(types): 工具箱分类信息添加 enabled 属性 2024-09-08 15:05:30 +08:00
68aec74c80 feat(工具箱): 完善逻辑,动态生成路由 2024-09-06 20:45:13 +08:00
b2e4937c69 fix(app): 外层元素添加 position 2024-09-06 20:43:47 +08:00
838856e53f chore(types): 添加路由 meta 属性说明 2024-09-06 20:42:32 +08:00
9ad6d3bb9d fix(app): 修改侧边栏路由跳转方式为 push() 2024-09-06 20:40:32 +08:00
c7a10c7e98 feat(工具箱): 完善界面,显示工具箱列表 2024-09-06 13:32:25 +08:00
9aed3ec064 feat(工具箱): 更新工具箱信息,添加图标 2024-09-06 13:32:02 +08:00
f1a7c74b93 chore(types): 更新工具箱信息类型定义,添加 iconClass 属性 2024-09-06 13:20:52 +08:00
d3a29eed91 docs(repo): 更新 README,添加其他版本的链接 2024-09-03 11:55:12 +08:00
ec35a111c7 feat(工具箱): 添加工具箱信息 2024-09-01 23:39:51 +08:00
171c8a404c chore(types): 添加工具箱信息类型定义 2024-09-01 23:39:22 +08:00
39 changed files with 3176 additions and 11 deletions

View File

@@ -1,5 +1,33 @@
# 更新日志
## V3.0.0
## [3.1.2] - 2024-10-13
### Added
- `工具箱` 添加“保持亮屏”。
- `工具箱` 添加“原神时钟”。
### Changed
- `工具箱` 调整页面路由生成逻辑,不跳过未启用的工具。
## [3.1.1] - 2024-09-08
### Fixed
- `配置文件` 解决打包异常。
## [3.1.0] - 2024-09-08
### Added
- `工具箱` 添加“工具箱”模块,包含“下载用时计算”“比例计算”“生成随机字符串”工具。
- `网址导航` 支持记录最后一次选中的链接分类。
### Changed
- `功能` 修改页面跳转方式,支持返回。
## [3.0.0] - 2024-09-01
重构新版本,支持“搜索”功能和“网址导航”功能。

View File

@@ -9,6 +9,9 @@
### 在线
- [GitHub Pages](https://frost-zx.github.io/frost-navigation/)
- [V1 版本](https://frost-zx.github.io/frost-navigation/v1/)2020-09-10
- [V2 版本](https://frost-zx.github.io/frost-navigation/v2/)2024-08-31
- [V3 版本](https://frost-zx.github.io/frost-navigation/v3/)(开发中)
- [Vercel](https://frost-navigation.vercel.app/)
### 离线(需要手动更新)

View File

@@ -1,7 +1,7 @@
{
"name": "frost-navigation",
"description": "Frost Navigation",
"version": "3.0.0",
"version": "3.1.2",
"type": "module",
"scripts": {
"dev": "vite",
@@ -14,7 +14,9 @@
"@mdi/font": "^7.4.47",
"@vueuse/core": "^11.0.3",
"axios": "^1.7.5",
"dayjs": "^1.11.13",
"lunisolar": "^2.5.0",
"mathjs": "^13.1.1",
"naive-ui": "^2.39.0",
"radash": "^12.1.0",
"uuid": "^10.0.0",

69
pnpm-lock.yaml generated
View File

@@ -20,9 +20,15 @@ importers:
axios:
specifier: ^1.7.5
version: 1.7.5
dayjs:
specifier: ^1.11.13
version: 1.11.13
lunisolar:
specifier: ^2.5.0
version: 2.5.0
mathjs:
specifier: ^13.1.1
version: 13.1.1
naive-ui:
specifier: ^2.39.0
version: 2.39.0(vue@3.4.38)
@@ -1231,6 +1237,9 @@ packages:
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
complex.js@2.1.1:
resolution: {integrity: sha512-8njCHOTtFFLtegk6zQo0kkVX1rngygb/KQI6z1qZxlFI3scluC+LVTCFbrkWjBv4vvLlbQ9t88IPMC6k95VTTg==}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -1273,6 +1282,9 @@ packages:
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
engines: {node: '>=0.11'}
dayjs@1.11.13:
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
debug@4.3.6:
resolution: {integrity: sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==}
engines: {node: '>=6.0'}
@@ -1282,6 +1294,9 @@ packages:
supports-color:
optional: true
decimal.js@10.4.3:
resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -1313,6 +1328,9 @@ packages:
resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==}
engines: {node: '>=6'}
escape-latex@1.2.0:
resolution: {integrity: sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==}
escape-string-regexp@1.0.5:
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
engines: {node: '>=0.8.0'}
@@ -1406,6 +1424,9 @@ packages:
resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==}
engines: {node: '>= 6'}
fraction.js@4.3.7:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
@@ -1511,6 +1532,9 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
javascript-natural-sort@0.7.1:
resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -1582,6 +1606,11 @@ packages:
resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==}
engines: {node: '>=6'}
mathjs@13.1.1:
resolution: {integrity: sha512-duaSAy7m4F+QtP1Dyv8MX2XuxcqpNDDlGly0SdVTCqpAmwdOFWilDdQKbLdo9RfD6IDNMOdo9tIsEaTXkconlQ==}
engines: {node: '>= 18'}
hasBin: true
meow@13.2.0:
resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==}
engines: {node: '>=18'}
@@ -1755,6 +1784,9 @@ packages:
sax@1.4.1:
resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==}
seedrandom@3.0.5:
resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==}
seemly@0.3.8:
resolution: {integrity: sha512-MW8Qs6vbzo0pHmDpFSYPna+lwpZ6Zk1ancbajw/7E8TKtHdV+1DfZZD+kKJEhG/cAoB/i+LiT+5msZOqj0DwRA==}
@@ -1824,6 +1856,9 @@ packages:
text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
tiny-emitter@2.1.0:
resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==}
to-fast-properties@2.0.0:
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
engines: {node: '>=4'}
@@ -1842,6 +1877,10 @@ packages:
resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
engines: {node: '>=10'}
typed-function@4.2.1:
resolution: {integrity: sha512-EGjWssW7Tsk4DGfE+5yluuljS1OGYWiI1J6e8puZz9nTMM51Oug8CD5Zo4gWMsOhq5BI+1bF+rWTm4Vbj3ivRA==}
engines: {node: '>= 18'}
undici-types@6.19.8:
resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
@@ -3297,6 +3336,8 @@ snapshots:
commander@2.20.3: {}
complex.js@2.1.1: {}
concat-map@0.0.1: {}
convert-source-map@2.0.0: {}
@@ -3336,10 +3377,14 @@ snapshots:
dependencies:
'@babel/runtime': 7.25.4
dayjs@1.11.13: {}
debug@4.3.6:
dependencies:
ms: 2.1.2
decimal.js@10.4.3: {}
deep-is@0.1.4: {}
delayed-stream@1.0.0: {}
@@ -3385,6 +3430,8 @@ snapshots:
escalade@3.1.2: {}
escape-latex@1.2.0: {}
escape-string-regexp@1.0.5: {}
escape-string-regexp@4.0.0: {}
@@ -3510,6 +3557,8 @@ snapshots:
combined-stream: 1.0.8
mime-types: 2.1.35
fraction.js@4.3.7: {}
fs.realpath@1.0.0: {}
fsevents@2.3.3:
@@ -3595,6 +3644,8 @@ snapshots:
isexe@2.0.0: {}
javascript-natural-sort@0.7.1: {}
js-tokens@4.0.0: {}
js-yaml@4.1.0:
@@ -3664,6 +3715,18 @@ snapshots:
semver: 5.7.2
optional: true
mathjs@13.1.1:
dependencies:
'@babel/runtime': 7.25.4
complex.js: 2.1.1
decimal.js: 10.4.3
escape-latex: 1.2.0
fraction.js: 4.3.7
javascript-natural-sort: 0.7.1
seedrandom: 3.0.5
tiny-emitter: 2.1.0
typed-function: 4.2.1
meow@13.2.0: {}
mime-db@1.52.0: {}
@@ -3855,6 +3918,8 @@ snapshots:
sax@1.4.1:
optional: true
seedrandom@3.0.5: {}
seemly@0.3.8: {}
semver@5.7.2:
@@ -3908,6 +3973,8 @@ snapshots:
text-table@0.2.0: {}
tiny-emitter@2.1.0: {}
to-fast-properties@2.0.0: {}
treemate@0.3.11: {}
@@ -3920,6 +3987,8 @@ snapshots:
type-fest@0.20.2: {}
typed-function@4.2.1: {}
undici-types@6.19.8: {}
unicode-canonical-property-names-ecmascript@2.0.0: {}

View File

@@ -188,6 +188,7 @@ html {
.app-view-header {
display: flex;
align-items: center;
position: relative;
padding: 0 16px;
height: 64px;
background-color: #FFF;
@@ -203,6 +204,7 @@ html {
.app-view-content {
flex-grow: 1;
position: relative;
padding: 20px;
width: 100%;
height: 0;

112
src/assets/js/svg-arc.js Normal file
View File

@@ -0,0 +1,112 @@
// 生成扇形、环形、圆形,或弧形的 SVG 路径
//
// 修改自
// https://github.com/svgcamp/svg-arc
// License
// MIT
const PI = Math.PI;
/**
* @param {number} x
* @param {number} y
* @param {number} r
* @param {number} angle
*/
function point(x, y, r, angle) {
return [
(x + Math.sin(angle) * r).toFixed(2),
(y - Math.cos(angle) * r).toFixed(2),
];
}
/**
* @param {number} x
* @param {number} y
* @param {number} R
* @param {number} r
*/
function full(x, y, R, r) {
if (r <= 0) {
return `M ${x - R} ${y} A ${R} ${R} 0 1 1 ${x + R} ${y} A ${R} ${R} 1 1 1 ${x - R} ${y} Z`;
}
return `M ${x - R} ${y} A ${R} ${R} 0 1 1 ${x + R} ${y} A ${R} ${R} 1 1 1 ${x - R} ${y} M ${x - r} ${y} A ${r} ${r} 0 1 1 ${x + r} ${y} A ${r} ${r} 1 1 1 ${x - r} ${y} Z`;
}
/**
* @param {number} x
* @param {number} y
* @param {number} R
* @param {number} r
* @param {number} start
* @param {number} end
*/
function part(x, y, R, r, start, end) {
let s = (start / 360) * 2 * PI;
let e = (end / 360) * 2 * PI;
let P = [
point(x, y, r, s),
point(x, y, R, s),
point(x, y, R, e),
point(x, y, r, e),
];
let flag = (e - s > PI ? '1' : '0');
return `M ${P[0][0]} ${P[0][1]} L ${P[1][0]} ${P[1][1]} A ${R} ${R} 0 ${flag} 1 ${P[2][0]} ${P[2][1]} L ${P[3][0]} ${P[3][1]} A ${r} ${r} 0 ${flag} 0 ${P[0][0]} ${P[0][1]} Z`;
}
// 关于角度:
// 12 点钟方向 - 0, 360, 720, ...
// 3 点钟方向 - 90, 450, ...
// 6 点钟方向 - 180, 540, ...
// 9 点钟方向 - 270, 630, ...
// 注意事项:
// 绘制环形时,需要将 `fill-rule` 属性的值设置为 `evenodd`,否则 `fill` 可能无法正确填充颜色。
/**
* @description 生成 SVG `<path>` 元素的 d 属性值
* @param {object} opts 配置选项
* @param {number} opts.x 圆心水平坐标
* @param {number} opts.y 圆心垂直坐标
* @param {number} opts.R 内层半径(用于圆环)
* @param {number} opts.r 外层半径(圆形半径)
* @param {number} opts.start 起始角度0 ~ 360
* @param {number} opts.end 结束角度0 ~ 360
*/
function arc(opts = {}) {
let { x = 0, y = 0 } = opts;
let { R = 0, r = 0, start, end } = opts;
[R, r] = [Math.max(R, r), Math.min(R, r)];
if (R <= 0) {
return '';
}
if (start !== +start || end !== +end) {
return full(x, y, R, r);
}
if (Math.abs(start - end) < 0.000001) {
return '';
}
if (Math.abs(start - end) % 360 < 0.000001) {
return full(x, y, R, r);
}
[start, end] = [start % 360, end % 360];
if (start > end) {
end += 360;
}
return part(x, y, R, r, start, end);
}
export default arc;

View File

@@ -0,0 +1,290 @@
// 工具箱
const MODULES = import.meta.glob('../../views/ToolboxView/**/*.vue');
/**
* @desc 工具列表
* @type {ToolboxCategory[]}
*/
export const toolList = [
{
id: 'calculation-tools',
title: '计算',
enabled: true,
items: [
{
id: 'calc-download-time',
component: 'Calculation/CalcDownloadTime',
title: '下载用时计算',
iconClass: 'mdi mdi-calculator-variant-outline',
desc: '根据设定的文件大小和下载速度简单计算大约下载完成所需的时间。',
createdAt: '2024-09-08',
updatedAt: '2024-09-08',
version: '1',
enabled: true,
},
{
id: 'calc-ratio',
component: 'Calculation/CalcRatio',
title: '比例计算',
iconClass: 'mdi mdi-calculator-variant-outline',
desc: '按设定的比例计算给出的数值所对应的数值。',
createdAt: '2024-09-08',
updatedAt: '2024-09-08',
version: '1',
enabled: true,
},
]
},
{
id: 'conversion-tools',
title: '转换',
enabled: false,
items: [
{
id: 'convert-html-entities',
component: 'Conversion/ConvertHtmlEntities',
title: '转换 HTML 实体',
iconClass: 'mdi mdi-swap-horizontal',
desc: '',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
},
{
id: 'url-encode-decode',
component: 'Conversion/UrlEncodeDecode',
title: 'URL 编码 / 解码',
iconClass: 'mdi mdi-swap-horizontal',
desc: '',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
},
{
id: 'convert-text-structure',
component: 'Conversion/ConvertTextStructure',
title: '文本结构转换',
iconClass: 'mdi mdi-swap-horizontal',
desc: '倒序、横竖互换等',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
},
{
id: 'convert-timestamp',
component: 'Conversion/ConvertTimestamp',
title: 'Unix 时间戳转换',
iconClass: 'mdi mdi-swap-horizontal',
desc: '时间戳转时间 / 时间转时间戳',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
},
]
},
{
id: 'edit-tools',
title: '编辑',
enabled: false,
items: [
{
id: 'csv-editor',
component: 'Edit/CsvEditor',
title: 'CSV 编辑工具',
iconClass: 'mdi mdi-table-edit',
desc: '查看或编辑 CSV 文件',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
},
],
},
{
id: 'generator-tools',
title: '生成',
enabled: true,
items: [
{
id: 'generate-urls',
component: 'Generator/GenerateUrls',
title: '生成批量下载链接',
iconClass: 'mdi mdi-link-variant',
desc: '根据设置,生成有一定规律的用于批量下载的链接。',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
},
{
id: 'generate-random-string',
component: 'Generator/GenerateRandomString',
title: '生成随机字符串',
iconClass: 'mdi mdi-format-text',
desc: '生成随机组合的字符串,可用于密码。',
createdAt: '2024-09-08',
updatedAt: '2024-09-08',
version: '1',
enabled: true,
},
]
},
{
id: 'minecraft-tools',
title: 'Minecraft',
enabled: false,
items: [
{
id: 'calc-minecraft-chunk-location',
component: 'Minecraft/CalcChunkLocation',
title: 'Minecraft 区块位置计算',
iconClass: 'mdi mdi-calculator-variant-outline',
desc: '',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
},
{
id: 'generate-minecraft-dynmap-renderdata',
component: 'Minecraft/GenerateDynmapRenderdata',
title: '生成 Dynmap renderdata',
iconClass: 'mdi mdi-file-outline',
desc: '生成用于 Minecraft Dynmap 插件或模组的 renderdata 数据。',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
},
{
id: 'minecraft-uuid-converter',
component: 'Minecraft/UuidConverter',
title: 'Minecraft UUID 转换',
iconClass: 'mdi mdi-identifier',
desc: '随机生成或转换 Minecraft 的 UUID。',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
},
]
},
{
id: 'network-tools',
title: '网络',
enabled: false,
items: [
{
id: 'websocket-test-tool',
component: 'Network/WebSocketTestTool',
title: 'WebSocket',
iconClass: 'mdi mdi-connection',
desc: 'WebSocket 测试工具',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
},
],
},
{
id: 'other-tools',
title: '其他',
enabled: true,
items: [
{
id: 'genshin-impact-clock',
component: 'Other/GenshinImpactClock/GenshinImpactClock',
title: '《原神》时钟',
iconClass: 'mdi mdi-clock-outline',
desc: '在网页上实现的《原神》时钟效果',
createdAt: '2024-10-13',
updatedAt: '2024-10-13',
version: '1',
enabled: true,
},
{
id: 'keep-screen-on',
component: 'Other/KeepScreenOn',
title: '保持亮屏',
iconClass: 'mdi mdi-monitor',
desc: '保持屏幕开启,不息屏,不休眠',
createdAt: '2024-10-11',
updatedAt: '2024-10-13',
version: '2',
enabled: true,
},
{
id: 'open-new-window',
component: 'Other/OpenNewWindow',
title: '新窗口(小窗)中打开',
iconClass: 'mdi mdi-window-maximize',
desc: '从新的小窗口中打开指定的链接(仅支持 PC 端浏览器)',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
},
{
id: 'run-javascript',
component: 'Other/RunJavaScript',
title: '执行 JavaScript',
iconClass: 'mdi mdi-code-braces',
desc: '执行简单的 JavaScript 代码片段',
createdAt: '',
updatedAt: '',
version: '0',
enabled: false,
},
],
},
];
/**
* @description 获取动态组件
* @param {string} path 工具页面相对路径
*/
function getDynamicComponent(path) {
let key = `../../views/ToolboxView/${path}.vue`;
let component = MODULES[key];
return component;
}
/** 生成工具箱页面路由 */
export function getToolboxRoutes() {
/** @type {VueRouteRecordRaw[]} */
let routes = [];
toolList.forEach((categoryItem) => {
categoryItem.items.forEach((toolItem) => {
// // 跳过未启用的工具
// if (!toolItem.enabled) {
// return;
// }
routes.push({
path: `/toolbox-view/${toolItem.id}`,
name: `Toolbox/${toolItem.component}`,
component: getDynamicComponent(toolItem.component),
meta: {
isToolDetail: true,
title: toolItem.title,
},
});
});
});
return routes;
}

View File

@@ -4,6 +4,36 @@ import {
description as appDesc,
} from '@package-json';
/** 将十六进制颜色值转为灰度值 */
export function colorHexToGrayLevel(hex = '') {
let rgb = colorHexToRgb(hex);
return Math.round(rgb.r * 0.299 + rgb.g * 0.587 + rgb.b * 0.114);
}
/** 将十六进制颜色值转为 RGB */
export function colorHexToRgb(hex = '') {
// 去除可能存在的 '#' 字符
hex = hex.replace('#', '');
// 检查十六进制颜色值的长度,并根据长度决定如何处理
if (hex.length === 3) {
// 如果是简写形式,如 #FFF需要将其转换为完整形式 #FFFFFF
hex = hex.split('').map(char => char + char).join('');
}
// 分别解析出红色、绿色和蓝色的值
let r = parseInt(hex.slice(0, 2), 16);
let g = parseInt(hex.slice(2, 4), 16);
let b = parseInt(hex.slice(4, 6), 16);
return { r, g, b };
}
/**
* @description 更新页面标题
* @param {string} title

View File

@@ -52,7 +52,7 @@ const router = useRouter();
/** 切换页面 */
function changePage(routeName = '') {
router.replace({ name: routeName });
router.push({ name: routeName });
}
/** 初始化数据 */

View File

@@ -27,7 +27,7 @@ export const SEARCH_MODULE_ENABLED = true;
export const SEARCH_MODULE_TITLE = '搜索';
/** 启用模块 */
export const TOOLBOX_MODULE_ENABLED = IS_DEV;
export const TOOLBOX_MODULE_ENABLED = true;
/** 模块标题 */
export const TOOLBOX_MODULE_TITLE = '工具箱';

View File

@@ -6,6 +6,9 @@ const PREFIX = 'frost-navigation/';
/** 导航链接侧边栏折叠状态 */
export const SKEY_NAV_LINK_ASIDE_COLLAPSED = PREFIX + 'nav-link-aside-collapsed';
/** 导航链接当前选中分类 */
export const SKEY_NAV_LINK_CATEGORY = PREFIX + 'nav-link-category';
/** 导航链接搜索类型 */
export const SKEY_NAV_LINK_SEARCH_TYPE = PREFIX + 'nav-link-search-type';

View File

@@ -4,6 +4,12 @@ import { router } from './router';
import '@mdi/font/css/materialdesignicons.css';
import '@/assets/fonts/index.css';
import dayjs from 'dayjs';
import dayjsDuration from 'dayjs/plugin/duration';
import 'dayjs/locale/zh-cn';
import 'dayjs/locale/zh-hk';
import lunisolar from 'lunisolar';
import lunisolarLang1 from 'lunisolar/locale/en';
import lunisolarLang2 from 'lunisolar/locale/zh-cn';
@@ -11,6 +17,12 @@ import lunisolarFestivals from 'lunisolar/markers/festivals.zh-cn';
import App from './App.vue';
// 加载 Day.js Duration 插件
dayjs.extend(dayjsDuration);
// 配置 Day.js 默认语言
dayjs.locale('zh-cn');
// 加载 Lunisolar 语言包
lunisolar.locale([
lunisolarLang1,

View File

@@ -3,6 +3,10 @@ import {
createWebHashHistory,
} from 'vue-router';
import {
getToolboxRoutes,
} from '@/assets/js/toolbox-data';
import {
updateAppTitle,
} from '@/assets/js/utils';
@@ -62,9 +66,11 @@ export const router = createRouter({
component: () => import('@/views/ToolboxView/ToolboxView.vue'),
meta: {
iconClass: 'mdi mdi-tools',
isToolDetail: false,
showInAside: TOOLBOX_MODULE_ENABLED,
title: TOOLBOX_MODULE_TITLE,
},
children: getToolboxRoutes(),
},
{
path: '/minecraft-ctrl-view',

View File

@@ -145,6 +145,7 @@ import {
import {
SKEY_NAV_LINK_ASIDE_COLLAPSED,
SKEY_NAV_LINK_CATEGORY,
SKEY_NAV_LINK_SEARCH_TYPE,
} from '@/config/storage';
@@ -228,10 +229,36 @@ const searchTypes = [
*/
function changeList(data = null) {
let useData = data || navLinksAll[0] || null;
let useData = null;
let storedKey = '';
if (data) {
useData = data;
} else {
storedKey = localStorage.getItem(SKEY_NAV_LINK_CATEGORY)
}
if (storedKey) {
useData = navLinksAll.find((item) => {
return item.title === storedKey;
});
}
if (!useData) {
useData = navLinksAll[0] || null;
}
if (useData) {
localStorage.setItem(SKEY_NAV_LINK_CATEGORY, useData.title);
navLinksCurr.value = useData.children;
navLinksTitle.value = useData.title;
} else {
localStorage.setItem(SKEY_NAV_LINK_CATEGORY, '');
navLinksCurr.value = [];
navLinksTitle.value = '';
}
navLinksCurr.value = useData ? useData.children : [];
navLinksTitle.value = useData ? useData.title : '';
}

View File

@@ -0,0 +1,209 @@
<template>
<div class="tool-detail-page">
<!-- 参数 -->
<n-card size="small" title="参数">
<n-form
class="form-no-feedback"
label-align="right"
label-placement="left"
label-width="auto"
>
<n-form-item label="文件大小:">
<n-input-group>
<n-input-number
v-model:value="inputs.sizeValue"
:precision="2"
:min="0"
:max="10000"
:step="1"
/>
<n-select
v-model:value="inputs.sizeUnit"
label-field="name"
value-field="name"
:options="units"
/>
</n-input-group>
</n-form-item>
<n-form-item label="已下载大小:">
<n-input-group>
<n-input-number
v-model:value="inputs.downloadedValue"
:precision="2"
:min="0"
:max="10000"
:step="1"
/>
<n-select
v-model:value="inputs.downloadedUnit"
label-field="name"
value-field="name"
:options="units"
/>
</n-input-group>
</n-form-item>
<n-form-item label="下载速度:">
<n-input-group>
<n-input-number
v-model:value="inputs.speedValue"
:precision="2"
:min="0"
:max="10000"
:step="1"
/>
<n-select
v-model:value="inputs.speedUnit"
label-field="speed"
value-field="name"
:options="units"
/>
</n-input-group>
</n-form-item>
</n-form>
</n-card>
<!-- 计算结果 -->
<n-card size="small" title="计算结果">
<n-form
class="form-no-feedback"
label-align="right"
label-placement="left"
label-width="auto"
>
<n-form-item label="大约需要时长:">
<span>{{ outputs.duration || '未计算' }}</span>
</n-form-item>
<n-form-item label="大约结束时间:">
<span>{{ outputs.time || '未计算' }}</span>
</n-form-item>
</n-form>
</n-card>
</div>
</template>
<script setup>
import {
NCard, NForm, NFormItem,
NInputGroup, NInputNumber, NSelect,
} from 'naive-ui';
import {
reactive, ref,
watch,
} from 'vue';
import {
$message,
} from '@/assets/js/naive-ui';
import dayjs from 'dayjs';
/** 单位和比率(基于 KB */
const units = [
{ name: 'KiB', speed: 'KiB/s', rate: 1 },
{ name: 'MiB', speed: 'MiB/s', rate: 1024 },
{ name: 'GiB', speed: 'GiB/s', rate: 1048576 },
];
/** 参数 */
const inputs = reactive({
downloadedUnit: 'KiB',
downloadedValue: 0,
sizeUnit: 'KiB',
sizeValue: 0,
speedUnit: 'KiB',
speedValue: 0,
});
/** 计算结果 */
const outputs = reactive({
duration: '',
time: '',
});
/** 防抖定时器 */
const timer = ref(null);
/** 计算结果 */
function calc() {
let {
downloadedUnit, downloadedValue,
sizeUnit, sizeValue,
speedUnit, speedValue,
} = inputs;
if (sizeValue === 0 || speedValue === 0) {
outputs.duration = '';
outputs.time = '';
return;
}
// 获取转换比例
let downloadedRate = units[units.findIndex((obj) => {
return (obj.name === downloadedUnit);
})].rate;
let sizeRate = units[units.findIndex((obj) => {
return (obj.name === sizeUnit);
})].rate;
let speedRate = units[units.findIndex((obj) => {
return (obj.name === speedUnit);
})].rate;
// 转为 KB 单位
let realDownloaded = downloadedValue * downloadedRate;
let realSize = sizeValue * sizeRate - realDownloaded;
let realSpeed = speedValue * speedRate;
if (realSize < 0) {
$message.warning('参数有误,请检查');
return;
}
// 时长(秒)
let dSeconds = (realSize / realSpeed).toFixed(0);
// 起始时间
let timeStart = dayjs();
// 结束时间
let timeEnd = timeStart.add(dSeconds, 'second');
// 时长(天,整数)
let dDays = timeEnd.diff(timeStart, 'day');
// 最后一天的起始时间
let timeLastDay = timeStart.add(dDays, 'day');
// 时长(格式化,最后一天剩余)
let dLastDay = dayjs.duration(timeEnd.diff(timeLastDay)).format('HH 时 mm 分 ss 秒');
outputs.duration = `${dDays}${dLastDay}`;
outputs.time = timeEnd.format('YYYY-MM-DD HH:mm:ss');
}
// 自动计算
watch(inputs, () => {
clearTimeout(timer.value);
timer.value = setTimeout(() => {
calc();
}, 1000);
}, { deep: true });
</script>
<style lang="less" scoped>
.tool-detail-page {
:deep(.n-input-number) {
width: 12em;
}
:deep(.n-select) {
width: 8em;
}
}
</style>

View File

@@ -0,0 +1,183 @@
<template>
<div class="tool-detail-page">
<!-- 计算模式 -->
<n-card size="small" title="计算模式">
<n-form-item
class="form-item-no-feedback"
:show-label="false"
>
<n-select
v-model:value="mode"
:options="[
{ label: '1 -> 2', value: '1-to-2' },
{ label: '2 -> 1', value: '2-to-1' },
]"
:on-blur="update"
/>
</n-form-item>
</n-card>
<!-- 小数位数 -->
<n-card size="small" title="小数位数">
<n-form-item
class="form-item-no-feedback"
:show-label="false"
>
<n-input-number
v-model:value="decimals"
:min="0"
:max="10"
:precision="0"
:step="1"
:on-blur="update"
></n-input-number>
</n-form-item>
</n-card>
<!-- 基础比例 -->
<n-card size="small" title="基础比例">
<n-form-item
class="form-item-no-feedback"
:show-label="false"
>
<n-input-number
v-model:value="base.a"
:min="limit.min"
:max="limit.max"
:precision="0"
:step="1"
:on-blur="update"
></n-input-number>
<span class="split">:</span>
<n-input-number
v-model:value="base.b"
:min="limit.min"
:max="limit.max"
:precision="0"
:step="1"
:on-blur="update"
></n-input-number>
</n-form-item>
</n-card>
<!-- 计算比例 -->
<n-card size="small" title="计算比例">
<n-form-item
class="form-item-no-feedback"
:show-label="false"
>
<n-input-number
v-model:value="calc.a"
:disabled="mode === '2-to-1'"
:step="1"
:on-blur="update"
></n-input-number>
<span class="split">:</span>
<n-input-number
v-model:value="calc.b"
:disabled="mode === '1-to-2'"
:step="1"
:on-blur="update"
></n-input-number>
</n-form-item>
</n-card>
</div>
</template>
<script setup>
import {
bignumber, divide, floor, multiply, number, round,
} from 'mathjs';
import {
NCard, NFormItem, NInputNumber, NSelect,
} from 'naive-ui';
import {
reactive, ref,
} from 'vue';
/** 基础比例 */
const base = reactive({
a: 1,
b: 1,
});
/** 计算比例 */
const calc = reactive({
a: 1,
b: 1,
});
/** 小数位数 */
const decimals = ref(5);
/** 数值范围限制 */
const limit = {
min: -99999999,
max: 99999999,
};
/** 模式 */
const mode = ref('1-to-2');
/** 计算 */
function calculate() {
let { min, max } = limit;
let useMode = mode.value;
let ratio = divide(bignumber(base.a), bignumber(base.b));
if (useMode === '1-to-2') {
// 注:只允许整数
let a = number(floor(calc.a));
(a < min) && (a = min);
(a > max) && (a = max);
calc.a = a;
calc.b = number(round(divide(bignumber(a), ratio), decimals.value));
} else if (useMode === '2-to-1') {
// 注:只允许整数
let b = number(floor(calc.b));
(b < min) && (b = min);
(b > max) && (b = max);
calc.a = number(round(multiply(bignumber(b), ratio), decimals.value));
calc.b = b;
}
}
/** 更新 */
function update() {
calculate();
}
</script>
<style lang="less" scoped>
.tool-detail-page {
:deep(.n-input-number) {
width: 12em;
}
:deep(.n-select) {
width: 12em;
}
}
.split {
display: inline-block;
width: 2em;
font-weight: bold;
text-align: center;
}
</style>

View File

@@ -0,0 +1,9 @@
<template>
<div class="tool-detail-page"></div>
</template>
<script setup>
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,9 @@
<template>
<div class="tool-detail-page"></div>
</template>
<script setup>
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,9 @@
<template>
<div class="tool-detail-page"></div>
</template>
<script setup>
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,9 @@
<template>
<div class="tool-detail-page"></div>
</template>
<script setup>
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,9 @@
<template>
<div class="tool-detail-page"></div>
</template>
<script setup>
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,246 @@
<template>
<div class="tool-detail-page">
<!-- 配置选项 -->
<n-card size="small" title="配置选项">
<n-form
class="form-no-feedback"
label-align="right"
label-placement="left"
label-width="auto"
>
<n-form-item label="字符串长度:">
<n-input-number
v-model:value="info.strLength"
:min="1"
:max="1024"
:step="1"
></n-input-number>
</n-form-item>
<n-form-item label="字符串格式:">
<n-flex>
<n-checkbox
v-model:checked="info.option.hasNum"
>数字</n-checkbox>
<n-checkbox
v-model:checked="info.option.hasChar"
>字母</n-checkbox>
<n-checkbox
v-model:checked="info.option.hasSymbol"
>其他符号</n-checkbox>
<n-checkbox
v-model:checked="info.option.caseSensitive"
>大小写</n-checkbox>
<n-checkbox
v-model:checked="info.option.lowerCase"
>全小写需关闭大小写</n-checkbox>
</n-flex>
</n-form-item>
</n-form>
</n-card>
<!-- 结果 -->
<n-card size="small" title="结果">
<n-form
class="form-no-feedback"
label-align="right"
label-placement="left"
label-width="auto"
>
<n-form-item>
<n-input
v-model:value="info.result"
placeholder=""
type="textarea"
:readonly="true"
:rows="3"
></n-input>
</n-form-item>
</n-form>
</n-card>
<!-- 操作 -->
<n-card size="small" title="操作">
<n-flex>
<n-button
type="primary"
@click="handleGenerate"
>生成字符串</n-button>
<n-button
type="primary"
:disabled="!info.result"
@click="handleCopy"
>复制结果</n-button>
</n-flex>
</n-card>
<!-- 算法参考 -->
<n-card size="small" title="算法参考">
<n-ul>
<n-li>
<n-a
href="https://www.cnblogs.com/hankuksui/p/9892729.html"
target="_blank"
>博客园 - hankuksui</n-a>
</n-li>
</n-ul>
</n-card>
</div>
</template>
<script setup>
import {
NA, NButton, NCard, NCheckbox,
NFlex, NForm, NFormItem,
NInput, NInputNumber, NLi, NUl,
} from 'naive-ui';
import {
reactive,
} from 'vue';
import {
$message,
} from '@/assets/js/naive-ui';
import {
useClipboard,
} from '@vueuse/core';
const clipboard = useClipboard({
legacy: true,
read: false,
});
const info = reactive({
strLength: 8,
option: {
caseSensitive: true,
hasNum: true,
hasChar: true,
hasSymbol: false,
lowerCase: false
},
result: ''
});
/**
* @description 生成随机字符串
* @param {number} strLength 长度
* @param {boolean} hasNum 是否包含数字
* @param {boolean} hasChar 是否包含字母
* @param {boolean} hasSymbol 是否包含其他符号
* @param {boolean} caseSensitive 是否包含大小写
* @param {boolean} lowerCase 是否全小写
* - 当 caseSensitive 为 false 时起作用
* @returns {string} 生成的字符串
*/
function genRandomStr(strLength, hasNum, hasChar, hasSymbol, caseSensitive, lowerCase) {
let result = '';
if (
hasNum === false &&
hasChar === false &&
hasSymbol === false
) {
return '请选中数字、字母或其他符号的其中一项!';
}
for (let i = strLength; i > 0; i--) {
let num = Math.floor((Math.random() * 94) + 33);
let flag = ((
(hasNum === false) && ((num >= 48) && (num <= 57))
) || (
(hasChar === false) && ((
(num >= 65) && (num <= 90)
) || (
(num >= 97) && (num <= 122)
))
) || (
(hasSymbol === false) && ((
(num >= 33) && (num <= 47)
) || (
(num >= 58) && (num <= 64)
) || (
(num >= 91) && (num <= 96)
) || (
(num >= 123) && (num <= 127)
)
)));
if (flag) {
i++;
continue;
}
/**
* | CharCode | 符号 |
* | :--------- | :----- |
* | 033 -> 047 | ! -> / |
* | 048 -> 057 | 0 -> 9 |
* | 058 -> 064 | : -> @ |
* | 065 -> 090 | A -> Z |
* | 091 -> 096 | [ -> ` |
* | 097 -> 122 | a -> z |
* | 123 -> 127 | { -> |
*/
result += String.fromCharCode(num);
}
if (caseSensitive === false) {
result = (lowerCase ? result.toLocaleLowerCase() : result.toUpperCase());
}
return result;
}
/** 处理复制操作 */
function handleCopy() {
if (clipboard.isSupported) {
return clipboard.copy(info.result).then(() => {
$message.success('复制成功');
}).catch((error) => {
console.error('复制失败:');
console.error(error);
$message.error('复制失败:异常');
});
} else {
$message.error('复制失败:当前浏览器不支持该操作');
return Promise.resolve(false);
}
}
/** 处理生成操作 */
function handleGenerate() {
let length = info.strLength;
let opt = info.option;
let result = genRandomStr(length, opt.hasNum, opt.hasChar, opt.hasSymbol, opt.caseSensitive, opt.lowerCase);
info.result = result;
}
</script>
<style lang="less" scoped>
.tool-detail-page {
:deep(.n-input-number) {
width: 8em;
}
:deep(.n-input--textarea) {
max-width: 48em;
}
}
</style>

View File

@@ -0,0 +1,9 @@
<template>
<div class="tool-detail-page"></div>
</template>
<script setup>
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,9 @@
<template>
<div class="tool-detail-page"></div>
</template>
<script setup>
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,9 @@
<template>
<div class="tool-detail-page"></div>
</template>
<script setup>
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,9 @@
<template>
<div class="tool-detail-page"></div>
</template>
<script setup>
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,9 @@
<template>
<div class="tool-detail-page"></div>
</template>
<script setup>
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,163 @@
<template>
<svg
class="clock-color"
:viewBox="`0 0 ${elSize} ${elSize}`"
>
<!-- 定义 -->
<defs>
<!-- 背景图案 -->
<pattern
id="color-pattern"
x="0"
y="0"
patternUnits="userSpaceOnUse"
patternContentUnits="userSpaceOnUse"
:width="elSize"
:height="elSize"
>
<image
:width="elSize"
:height="elSize"
:href="IMAGE_TIME_ZONE_COLOR"
/>
</pattern>
</defs>
<!-- 外层圆环 -->
<path
class="color-circle"
:class="{ faded: currAngle > 360 }"
:d="state.dOuter"
fill="url(#color-pattern)"
fill-rule="evenodd"
></path>
<!-- 内层圆环 -->
<path
ref="innerCircle"
class="color-circle"
:d="state.dInner"
fill="url(#color-pattern)"
fill-rule="evenodd"
></path>
</svg>
</template>
<script setup>
import {
computed,
onMounted,
reactive, watch,
} from 'vue';
import {
IMAGE_TIME_ZONE_COLOR,
} from './common-data';
import arc from '@/assets/js/svg-arc';
const props = defineProps({
elSize: {
type: Number,
default: 300,
},
currAngle: {
type: Number,
default: 0,
},
startAngle: {
type: Number,
default: 0,
},
radius: {
type: Number,
default: 150,
},
thickness: {
type: Number,
default: 8,
},
});
const state = reactive({
dInner: '',
dOuter: '',
});
const halfSize = computed(() => {
return (props.elSize / 2);
});
// 角度变化时更新状态
watch(() => (props.currAngle), () => {
updateCircle();
});
watch(() => (props.startAngle), () => {
updateCircle();
});
/** 更新圆环状态 */
function updateCircle() {
const { currAngle, startAngle, thickness } = props;
const radius = halfSize.value;
const size = thickness;
const offset = size / 4;
const endAngleInner = Math.max(0, currAngle - 360) + startAngle;
const endAngleOuter = Math.min(360, currAngle) + startAngle;
// 内层圆环
state.dInner = arc({
x: radius,
y: radius,
R: radius - size * 2 - offset,
r: radius - size - offset,
start: startAngle,
end: endAngleInner,
});
// 外层圆环
state.dOuter = arc({
x: radius,
y: radius,
R: radius - size,
r: radius,
start: startAngle,
end: endAngleOuter,
});
}
onMounted(() => {
updateCircle();
});
</script>
<style lang="less">
.clock-color {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
.color-circle {
filter: brightness(1);
transition: filter 0.25s;
}
.faded {
filter: brightness(0.6);
}
}
</style>

View File

@@ -0,0 +1,728 @@
<template>
<div
ref="clockElement"
:class="{
'clock-element': true,
'clock-rotation': clockState.isRotation,
}"
:style="elStyle"
@touchmove.prevent
>
<!-- 外部 -->
<div class="clock-outer bg-contain"></div>
<!-- 内部 -->
<div class="clock-inner">
<!-- 背景 -->
<div class="inner-bg bg-cover"></div>
<div class="inner-star bg-cover"></div>
<!-- 齿轮 -->
<div class="clock-gear">
<div class="gear-6"></div>
<div class="gear-5"></div>
<div class="gear-4"></div>
<div class="gear-3"></div>
<div class="gear-2"></div>
<div class="gear-1"></div>
</div>
<!-- 色环 -->
<clock-color
:curr-angle="upperRealAngle"
:start-angle="lowerPointer.viewAngle"
/>
<!-- 下层指针 -->
<div class="pointer-wrapper pointer-lower bg-contain">
<div class="pointer-content"></div>
</div>
<!-- 上层指针 -->
<div class="pointer-wrapper pointer-upper bg-contain">
<div
class="pointer-content"
@pointerdown="handleDragPointer('upper')"
></div>
</div>
</div>
<!-- 表盘 -->
<div class="clock-dial bg-contain">
<div class="time-icons">
<div
class="time-morning bg-contain"
style="--self-angle-1: 270; --self-angle-2: 270;"
></div>
<div
class="time-noon bg-contain"
style="--self-angle-1: 0; --self-angle-2: 360;"
></div>
<div
class="time-dusk bg-contain"
style="--self-angle-1: 90; --self-angle-2: 90;"
></div>
<div
class="time-night bg-contain"
style="--self-angle-1: 180; --self-angle-2: 180;"
></div>
</div>
</div>
<!-- 遮罩层用于阻止操作 -->
<div v-show="isAutoRotating" class="clock-mask"></div>
</div>
</template>
<script setup>
import {
computed,
onBeforeUnmount, onMounted,
reactive, ref,
watch,
} from 'vue';
import {
IMAGE_CLOCK_BG_INNER, IMAGE_CLOCK_BG_OUTER, IMAGE_CLOCK_DIAL,
IMAGE_CLOCK_GEAR_1, IMAGE_CLOCK_GEAR_4,
IMAGE_CLOCK_GEAR_5, IMAGE_CLOCK_GEAR_6,
IMAGE_CLOCK_PARTICLES, IMAGE_POINTER_LOWER, IMAGE_POINTER_UPPER,
IMAGE_TIME_ICON_DUSK, IMAGE_TIME_ICON_MORNING,
IMAGE_TIME_ICON_NIGHT, IMAGE_TIME_ICON_NOON,
isAutoRotating, isTimeExceeded, isTimeTooEarly,
timeCurrHour, timeCurrMinute, timeCurrValue,
timeDiffLabel, timeDiffLabelStill,
timeNewHour, timeNewMinute, timeNewValue,
} from './common-data';
import ClockColor from './ClockColor.vue';
const clockElement = ref(null);
/** 时钟状态 */
const clockState = reactive({
/** 指针是否正在旋转 */
isRotation: false,
/** 最新一次获取到的指针角度 */
lastAngle: 0,
/** 定时器 ID */
rotationWatcher: null,
});
/** 下层指针状态 */
const lowerPointer = reactive({
/** 视图角度,用于显示 */
viewAngle: 0,
});
/** 上层指针状态 */
const upperPointer = reactive({
/** 是否为第二圈 */
isSecond: false,
/** 上层指针与下层指针相差的角度 */
dataAngle: 0,
/** 视图角度,用于显示和交互 */
viewAngle: 0,
});
window.upperPointer = upperPointer;
/** 元素 CSS */
const elStyle = computed(() => {
return {
'--image-clock-bg-inner': IMAGE_CLOCK_BG_INNER,
'--image-clock-bg-outer': IMAGE_CLOCK_BG_OUTER,
'--image-clock-dial': IMAGE_CLOCK_DIAL,
'--image-clock-gear-1': IMAGE_CLOCK_GEAR_1,
'--image-clock-gear-4': IMAGE_CLOCK_GEAR_4,
'--image-clock-gear-5': IMAGE_CLOCK_GEAR_5,
'--image-clock-gear-6': IMAGE_CLOCK_GEAR_6,
'--image-clock-particles': IMAGE_CLOCK_PARTICLES,
'--image-pointer-lower': IMAGE_POINTER_LOWER,
'--image-pointer-upper': IMAGE_POINTER_UPPER,
'--image-time-icon-dusk': IMAGE_TIME_ICON_DUSK,
'--image-time-icon-morning': IMAGE_TIME_ICON_MORNING,
'--image-time-icon-night': IMAGE_TIME_ICON_NIGHT,
'--image-time-icon-noon': IMAGE_TIME_ICON_NOON,
'--pointer-lower-angle': `${lowerPointer.viewAngle}deg`,
'--pointer-lower-angle-value': lowerPointer.viewAngle,
'--pointer-upper-angle': `${upperPointer.viewAngle}deg`,
'--pointer-upper-angle-value': upperPointer.viewAngle,
};
});
/** 上层指针实际已旋转角度 */
const upperRealAngle = computed(() => {
const { dataAngle, isSecond } = upperPointer;
return (isSecond ? dataAngle + 360 : dataAngle);
});
/** 初始化定时器 */
function timerInit() {
// 指针旋转检测
clockState.rotationWatcher = setInterval(function () {
const currAngle = upperRealAngle.value;
const lastAngle = clockState.lastAngle;
clockState.isRotation = (Math.abs(currAngle - lastAngle) >= 5);
clockState.lastAngle = currAngle;
}, 250);
}
/** 重置定时器 */
function timerReset() {
clearInterval(clockState.rotationWatcher);
}
/**
* @description 获取元素中心坐标
* @param {HTMLElement} el
*/
function getCenterPoint(el) {
if (el) {
const rect = el.getBoundingClientRect();
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
} else {
return null;
}
}
/** 处理拖拽上层指针 */
function handleDragPointer() {
const center = getCenterPoint(clockElement.value);
if (!center) {
return;
}
const centerX = center.x;
const centerY = center.y;
// 节流
let last = 0;
/**
* @description 处理光标移动
* @param {PointerEvent} ev
*/
let handleMove = function (ev) {
let curr = Date.now();
if (curr - last >= 20) {
last = curr;
} else {
return;
}
let { pageX, pageY } = ev;
let numX = pageX - centerX;
let numY = pageY - centerY;
// 计算两点弧度 & 转换为角度(-180 ~ 180
let calcAngle = Math.round(Math.atan2(numY, numX) * (180 / Math.PI));
// 转换为视图角度0 ~ 359
let viewAngle = calcAngle + (calcAngle >= -90 ? 90 : 450);
// 用于数据处理的角度
let dataAngle = 0;
// 起始角度偏移值
let offsetAngle = lowerPointer.viewAngle;
// 处理偏移,获取数据角度
if (viewAngle >= offsetAngle) {
dataAngle = viewAngle - offsetAngle;
} else if (viewAngle < offsetAngle) {
dataAngle = viewAngle + (360 - offsetAngle);
}
// 新角度与原角度的差值
let diff = dataAngle - upperPointer.dataAngle;
// 顺时针越过起始点
if (diff <= -180) {
if (upperPointer.isSecond) {
// 当前为第二圈,阻止移动
dataAngle = 360;
viewAngle = 360 + offsetAngle;
} else {
// 当前为第一圈,进入第二圈
upperPointer.isSecond = true;
}
}
// 逆时针越过起始点
if (diff >= 180) {
if (upperPointer.isSecond) {
// 当前为第二圈,返回第一圈
upperPointer.isSecond = false;
} else {
// 当前为第一圈,阻止移动
dataAngle = 0;
viewAngle = offsetAngle;
}
}
upperPointer.dataAngle = dataAngle;
upperPointer.viewAngle = viewAngle;
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', function () {
window.removeEventListener('pointermove', handleMove);
}, { once: true });
}
/** 确定当前选择的时间,下层指针旋转至上层指针的位置 */
function handleSubmitTime() {
let lowerAngleStart = lowerPointer.viewAngle;
let upperAngleStart = upperRealAngle.value;
let upperAngleCurr = upperAngleStart;
let timer = setInterval(() => {
// 每次 -2最小值为 0
upperAngleCurr = Math.max(0, upperAngleCurr - 2);
// 结束
if (upperAngleCurr === 0) {
clearInterval(timer);
isAutoRotating.value = false;
timeDiffLabelStill.value = '';
}
let upperAngleDiff = upperAngleStart - upperAngleCurr;
let lowerAngleCurr = lowerAngleStart + upperAngleDiff;
lowerPointer.viewAngle = lowerAngleCurr % 360;
upperPointer.dataAngle = upperAngleCurr % 360;
upperPointer.isSecond = (upperAngleCurr >= 360);
}, 50);
/** 更新状态 */
isAutoRotating.value = true;
// 固定时间差文本
timeDiffLabelStill.value = timeDiffLabel.value;
}
defineExpose({
handleSubmitTime,
});
// 检测角度变化,计算时间信息(自动旋转时)
watch(() => {
return lowerPointer.viewAngle;
}, (viewAngle) => {
// 转换为对应 24 小时的角度
let timeAngle = viewAngle + (viewAngle < 180 ? 180 : -180);
let timeValue = timeAngle / 15;
let currHour = Math.floor(timeValue);
let currMinute = Math.round((timeValue - currHour) * 60);
// 计算时间值
timeCurrHour.value = String(currHour).padStart(2, '0');
timeCurrMinute.value = String(currMinute).padStart(2, '0');
timeCurrValue.value = timeValue;
}, { immediate: true });
// 检测角度变化,计算时间信息(用户操作时)
watch(() => {
return upperPointer.dataAngle;
}, (dataAngle) => {
// 注15° / 小时
let isSecond = upperPointer.isSecond;
let currAngle = lowerPointer.viewAngle;
let viewAngle = upperPointer.viewAngle;
let diffAngle = dataAngle + (isSecond ? 360 : 0);
let diffAngle1 = 0; // +1 日角度差
let diffAngle2 = 0; // +2 日角度差
let diffLabel = '';
// 转换为对应 24 小时的角度
let timeAngle = viewAngle + (viewAngle < 180 ? 180 : -180);
let timeValue = timeAngle / 15;
let newHour = Math.floor(timeValue);
let newMinute = Math.round((timeValue - newHour) * 60);
if (currAngle < 180) {
diffAngle1 = 180 - currAngle;
diffAngle2 = diffAngle1 + 360;
} else {
diffAngle1 = 540 - currAngle; // 360 + 180
diffAngle2 = diffAngle1 + 360;
}
// 处理时间差信息
if (diffAngle < diffAngle1) {
diffLabel = '今日';
} else if (diffAngle < diffAngle2) {
diffLabel = '次日';
} else {
diffLabel = '+2日';
}
// 处理提示信息显示
isTimeTooEarly.value = diffAngle < 7.5;
isTimeExceeded.value = diffAngle === 720;
// 更新时间差信息
timeDiffLabel.value = diffLabel;
// 计算时间值
timeNewHour.value = String(newHour).padStart(2, '0');
timeNewMinute.value = String(newMinute).padStart(2, '0');
timeNewValue.value = timeValue;
}, { immediate: true });
onMounted(() => {
timerInit();
});
onBeforeUnmount(() => {
timerReset();
});
</script>
<style lang="less" scoped>
.bg-contain {
background-position: center;
background-repeat: no-repeat;
background-size: contain;
}
.bg-cover {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
.clock-element {
--pointer-lower-angle: 0deg;
--pointer-upper-angle: 0deg;
position: relative;
width: 32em;
height: 32em;
border-radius: 50%;
font-size: 12px;
overflow: hidden;
filter: brightness(1.1) saturate(0.9);
}
.clock-outer {
position: absolute;
top: -1%;
left: -1%;
width: 102%;
height: 102%;
background-image: var(--image-clock-bg-outer);
}
.clock-inner {
--size: 46%;
position: absolute;
top: calc(50% - var(--size) / 2);
left: calc(50% - var(--size) / 2);
width: var(--size);
height: var(--size);
border-radius: 50%;
background-color: #000;
overflow: hidden;
.inner-bg, .inner-star {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.inner-bg {
background-image: var(--image-clock-bg-inner);
animation: rotation-backward 60s linear infinite;
}
.inner-star {
background-image: var(--image-clock-particles);
}
}
.clock-gear {
--size: 120%;
--ratio: 1.55;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.6;
> div {
--ani-duration: 2s;
position: absolute;
animation-duration: var(--ani-duration);
animation-iteration-count: infinite;
animation-timing-function: linear;
transform: rotate(0);
will-change: transform;
&::after {
display: block;
content: "";
width: 100%;
height: 100%;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
animation-duration: calc(var(--ani-duration) / 12);
animation-iteration-count: infinite;
animation-name: inherit;
animation-play-state: paused;
animation-timing-function: linear;
}
}
.gear-1 {
--ani-duration: calc(1s * var(--gear-teeth) * var(--ratio));
--gear-teeth: 32;
top: calc(50% - var(--size) / 2);
left: calc(50% - var(--size) / 2);
width: var(--size);
height: var(--size);
animation-name: rotation-backward;
&::after {
background-image: var(--image-clock-gear-1);
}
}
.gear-2 {
--ani-duration: calc(1s * var(--gear-teeth) * var(--ratio));
--gear-teeth: 32;
top: calc(0% - var(--size) / 2);
left: calc(72% - var(--size) / 2);
width: var(--size);
height: var(--size);
animation-name: rotation-backward;
&::after {
background-image: var(--image-clock-gear-1);
}
}
.gear-3 {
--ani-duration: calc(1s * var(--gear-teeth) * var(--ratio));
--gear-teeth: 32;
top: calc(77% - var(--size) / 2);
left: calc(100% - var(--size) / 2);
width: var(--size);
height: var(--size);
animation-name: rotation-backward;
opacity: 0.5;
&::after {
background-image: var(--image-clock-gear-1);
}
}
.gear-4 {
--angle-offset: 4deg;
--ani-duration: calc(1s * var(--gear-teeth) * var(--ratio));
--gear-teeth: 32;
top: calc(77% - var(--size) / 2);
left: calc(100% - var(--size) / 2);
width: var(--size);
height: var(--size);
animation-name: rotation-forward;
&::after {
background-image: var(--image-clock-gear-4);
}
}
// 正常:约 74s / 圈
// 加速:约 7s / 圈
.gear-5 {
--ani-duration: calc(1s * (var(--gear-teeth) - 1) * var(--ratio));
--gear-teeth: 49;
top: calc(60% - var(--size) / 2);
left: calc(33% - var(--size) / 2);
width: var(--size);
height: var(--size);
animation-name: rotation-backward;
&::after {
background-image: var(--image-clock-gear-5);
}
}
.gear-6 {
--ani-duration: calc(1s * var(--gear-teeth) * var(--ratio));
--gear-teeth: 49;
top: calc(42% - var(--size) / 2);
left: calc(62% - var(--size) / 2);
width: var(--size);
height: var(--size);
animation-name: rotation-backward;
&::after {
background-image: var(--image-clock-gear-6);
}
}
}
// 齿轮加速转动
.clock-rotation .clock-gear > div::after {
animation-play-state: running;
}
.pointer-wrapper {
--size: 180%;
position: absolute;
top: 50%;
left: 50%;
width: var(--size);
height: var(--size);
&.pointer-lower {
background-image: var(--image-pointer-lower);
transform: translate(-50%, -50%) rotate(var(--pointer-lower-angle));
}
&.pointer-upper {
background-image: var(--image-pointer-upper);
transform: translate(-50%, -50%) rotate(var(--pointer-upper-angle));
}
.pointer-content {
--width: 5%;
position: absolute;
left: calc(50% - var(--width) / 2);
width: var(--width);
height: 50%;
cursor: grab;
}
}
.clock-dial {
--size: 88%;
position: absolute;
top: calc(50% - var(--size) / 2);
left: calc(50% - var(--size) / 2);
width: var(--size);
height: var(--size);
background-image: var(--image-clock-dial);
pointer-events: none;
.time-icons {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
> div {
// [角度 1 相关计算]
// 计算指针角度与图标对应角度的差值(可能为负数)
--angle-1-diff: calc(var(--self-angle-1) - var(--pointer-lower-angle-value));
// 计算角度差值的绝对值
--angle-1-abs: ~"max(var(--angle-1-diff), var(--angle-1-diff) * -1)";
// 限制角度最大差值为 90
--angle-1-use: ~"min(90, var(--angle-1-abs))";
// [角度 2 相关计算,主要用于 360°]
// 计算指针角度与图标所在角度的差值(可能为负数)
--angle-2-diff: calc(var(--self-angle-2) - var(--pointer-lower-angle-value));
// 计算角度差值的绝对值
--angle-2-abs: ~"max(var(--angle-2-diff), var(--angle-2-diff) * -1)";
// 限制角度最大差值为 90
--angle-2-use: ~"min(90, var(--angle-2-abs))";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
// 根据角度计算透明度
// 注:计算两个角度的透明度,取最大值
opacity: ~"calc((90 - min(var(--angle-1-use), var(--angle-2-use))) / 90)";
}
.time-morning {
background-image: var(--image-time-icon-morning);
}
.time-noon {
background-image: var(--image-time-icon-noon);
}
.time-dusk {
background-image: var(--image-time-icon-dusk);
}
.time-night {
background-image: var(--image-time-icon-night);
}
}
}
.clock-mask {
position: absolute;
left: 0;
top: 0;
z-index: 100;
width: 100%;
height: 100%;
cursor: wait;
}
// 顺时针旋转
@keyframes rotation-forward {
0% {
transform: rotate(calc(0deg + var(--angle-offset, 0deg)));
}
100% {
transform: rotate(calc(360deg + var(--angle-offset, 0deg)));
}
}
// 逆时针旋转
@keyframes rotation-backward {
0% {
transform: rotate(calc(360deg + var(--angle-offset, 0deg)));
}
100% {
transform: rotate(calc(0deg + var(--angle-offset, 0deg)));
}
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<div class="genshin-button">
<span
v-if="hasIcon"
class="btn-icon mdi"
:class="iconName"
:style="{ color: iconColor }"
></span>
<span class="btn-label">
<slot></slot>
</span>
</div>
</template>
<script setup>
defineProps({
hasIcon: {
type: Boolean,
default: true,
},
iconColor: {
type: String,
default: '#FFFFFF',
},
iconName: {
type: String,
default: 'mdi-help',
},
});
</script>
<style lang="less" scoped>
.genshin-button {
display: inline-block;
position: relative;
padding: 8px;
border-radius: 24px;
background-color: #ECE3D6;
color: #494246;
font-size: 16px;
font-weight: bold;
line-height: 1;
white-space: nowrap;
opacity: 1;
transition: opacity 0.25s;
cursor: pointer;
&:hover {
opacity: 0.5;
}
span {
vertical-align: middle;
}
}
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
border-radius: 50%;
background-color: #2D2D2D;
color: #FFFFFF;
}
.btn-label {
display: inline-block;
padding: 0 56px;
}
</style>

View File

@@ -0,0 +1,221 @@
<template>
<div class="tool-detail-page has-radius">
<!-- 背景图片 -->
<div
class="page-bg-wrapper"
:style="{ '--curr-time': Number(timeCurrValue) }"
>
<div
:style="{
'--bg-url': IMAGE_BG_DUSK,
'--self-hour-1': 18,
'--self-hour-2': 18,
}"
></div>
<div
:style="{
'--bg-url': IMAGE_BG_MORNING,
'--self-hour-1': 6,
'--self-hour-2': 6,
}"
></div>
<div
:style="{
'--bg-url': IMAGE_BG_NIGHT,
'--self-hour-1': 0,
'--self-hour-2': 24,
}"
></div>
<div
:style="{
'--bg-url': IMAGE_BG_NOON,
'--self-hour-1': 12,
'--self-hour-2': 12,
}"
></div>
<div class="page-bg-mask"></div>
</div>
<!-- -->
<div class="page-column">
<time-info />
</div>
<!-- -->
<div class="page-column">
<!-- 时钟 -->
<clock-element ref="clockRef" />
<!-- 上限提示 -->
<div
:class="{
'is-hide': isAutoRotating,
'time-notice': true,
}"
>
<span
v-show="isTimeExceeded"
class="time-notice-text"
>时间到达上限</span>
</div>
<!-- 确认时间 -->
<div
:class="{
'is-hide': isAutoRotating,
'time-submit': true,
}"
>
<span
v-if="isTimeTooEarly"
class="time-notice-text"
>时间少于30分钟</span>
<genshin-button
v-else
icon-color="#FFC107"
icon-name="mdi-circle-outline"
@click="handleConfirm"
>确认</genshin-button>
</div>
</div>
</div>
</template>
<script setup>
import {
ref,
} from 'vue';
import {
IMAGE_BG_DUSK,
IMAGE_BG_MORNING,
IMAGE_BG_NIGHT,
IMAGE_BG_NOON,
isAutoRotating,
isTimeTooEarly,
isTimeExceeded,
timeCurrValue,
} from './common-data';
import ClockElement from './ClockElement.vue';
import GenshinButton from './GenshinButton.vue';
import TimeInfo from './TimeInfo.vue';
/**
* @desc 时钟元素 ref
* @type {VueRef<InstanceType<ClockElement>>}
*/
const clockRef = ref(null);
/** 处理点击确认按钮 */
function handleConfirm() {
let el = clockRef.value;
if (el) {
el.handleSubmitTime();
}
}
</script>
<style lang="less" scoped>
.tool-detail-page {
position: relative;
background-color: #000;
font-size: 16px;
text-align: center;
white-space: nowrap;
}
.page-bg-wrapper {
position: absolute;
left: 0;
top: 0;
z-index: 0;
width: 100%;
height: 100%;
> div {
// [小时 1 相关计算]
// 计算当前小时与背景对应小时的差值(可能为负数)
--hour-1-diff: calc(var(--self-hour-1) - var(--curr-time));
// 计算小时差值的绝对值
--hour-1-abs: ~"max(var(--hour-1-diff), var(--hour-1-diff) * -1)";
// 限制小时最大差值为 6
--hour-1-use: ~"min(6, var(--hour-1-abs))";
// [小时 2 相关计算,主要用于 0 点]
// 计算当前小时与背景所在小时的差值(可能为负数)
--hour-2-diff: calc(var(--self-hour-2) - var(--curr-time));
// 计算小时差值的绝对值
--hour-2-abs: ~"max(var(--hour-2-diff), var(--hour-2-diff) * -1)";
// 限制小时最大差值为 6
--hour-2-use: ~"min(6, var(--hour-2-abs))";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-image: var(--bg-url);
background-position: center;
background-repeat: no-repeat;
background-size: cover;
// 根据小时计算透明度
// 注:计算两个小时的透明度,取最大值
opacity: ~"calc((6 - min(var(--hour-1-use), var(--hour-2-use))) / 6)";
}
.page-bg-mask {
background-color: #000;
opacity: 0.75;
}
}
.page-column {
display: inline-flex;
align-items: center;
flex-direction: column;
justify-content: center;;
position: relative;
z-index: 10;
vertical-align: middle;
height: 100%;
white-space: initial;
&:not(:first-child) {
margin-left: 64px;
}
}
.time-notice, .time-submit {
display: flex;
height: 40px;
opacity: 1;
visibility: visible;
transition: opacity 0.25s, visibility 0s 0s;
// 注:
// visibility 动画时长用于等待 opacity 动画过渡完毕
&.is-hide {
visibility: hidden;
opacity: 0;
transition: opacity 0.25s, visibility 0.25s 0s;
}
}
.time-notice {
margin-top: 16px;
}
.time-notice-text {
margin: auto 0;
font-weight: bold;
color: #FFF;
opacity: 0.5;
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,128 @@
<template>
<div
class="time-info"
:style="{ '--image-time-info-arrow': IMAGE_TIME_INFO_ARROW }"
>
<!-- 装饰元素 -->
<div class="arrow-element"></div>
<!-- 标题 -->
<div class="time-title time-title-current">当前时间</div>
<!-- 当前时间值 -->
<div class="time-value time-value-current">
<span>{{ timeCurrHour }}</span>
<span>:</span>
<span>{{ timeCurrMinute }}</span>
</div>
<!-- 三角形图标 -->
<div class="triangle-icon">
<span class="mdi mdi-triangle-small-down"></span>
</div>
<!-- 标题 -->
<div class="time-title time-title-target">调整到</div>
<!-- 三角形图标 -->
<div class="triangle-icon">
<span class="mdi mdi-triangle-small-down"></span>
</div>
<!-- 目标时间值 -->
<div class="time-value time-value-target">
<span>{{ timeNewHour }}</span>
<span>:</span>
<span>{{ timeNewMinute }}</span>
</div>
<!-- 时间差 -->
<div class="time-diff">
<span>{{ timeDiffLabelStill || timeDiffLabel }}</span>
</div>
<!-- 装饰元素 -->
<div class="arrow-element"></div>
</div>
</template>
<script setup>
import {
IMAGE_TIME_INFO_ARROW,
timeCurrHour, timeCurrMinute,
timeDiffLabel, timeDiffLabelStill,
timeNewHour, timeNewMinute,
} from './common-data';
</script>
<style lang="less" scoped>
.time-info {
display: flex;
align-items: center;
flex-direction: column;
width: 8em;
font-size: 16px;
font-weight: bold;
line-height: 1;
}
.arrow-element {
margin: 1.75em 0;
width: 100%;
height: 1.5em;
background-image: var(--image-time-info-arrow);
background-position: center;
background-repeat: no-repeat;
background-size: contain;
}
.time-diff {
display: flex;
width: 3.5em;
height: 1.75em;
border-radius: 1.75em;
background-color: #282C33;
color: rgba(255, 255, 255, 0.75);
font-size: 0.75em;
span {
margin: auto;
}
}
.time-title {
color: #AFA189;
}
.time-title-target {
margin: 0.25em 0;
}
.time-value {
color: #ECE3D6;
font-size: 1.25em;
}
.time-value-current {
margin-top: 1.25em;
margin-bottom: 0.25em;
}
.time-value-target {
margin-top: 0.25em;
margin-bottom: 0.5em;
}
.triangle-icon {
display: flex;
align-items: center;
justify-content: center;
width: 1em;
height: 1em;
color: #FFF;
font-size: 1.5em;
opacity: 0.2;
}
</style>

View File

@@ -0,0 +1,56 @@
import { ref } from "vue";
export const IMAGE_BASE = `https://c.frost-zx.top/data/static/image/genshin-impact-clock`;
export const IMAGE_BG_DUSK = `url("${IMAGE_BASE}/bg_dusk.png")`;
export const IMAGE_BG_MORNING = `url("${IMAGE_BASE}/bg_morning.png")`;
export const IMAGE_BG_NIGHT = `url("${IMAGE_BASE}/bg_night.png")`;
export const IMAGE_BG_NOON = `url("${IMAGE_BASE}/bg_noon.png")`;
export const IMAGE_CLOCK_BG_INNER = `url("${IMAGE_BASE}/clock_bg_inner.png")`;
export const IMAGE_CLOCK_BG_OUTER = `url("${IMAGE_BASE}/clock_bg_outer.png")`;
export const IMAGE_CLOCK_DIAL = `url("${IMAGE_BASE}/clock_dial.png")`;
export const IMAGE_CLOCK_GEAR_1 = `url("${IMAGE_BASE}/clock_gear_1.png")`;
export const IMAGE_CLOCK_GEAR_4 = `url("${IMAGE_BASE}/clock_gear_4.png")`;
export const IMAGE_CLOCK_GEAR_5 = `url("${IMAGE_BASE}/clock_gear_5.png")`;
export const IMAGE_CLOCK_GEAR_6 = `url("${IMAGE_BASE}/clock_gear_6.png")`;
export const IMAGE_CLOCK_PARTICLES = `url("${IMAGE_BASE}/clock_particles.gif")`;
export const IMAGE_POINTER_LOWER = `url("${IMAGE_BASE}/pointer_lower.png")`;
export const IMAGE_POINTER_UPPER = `url("${IMAGE_BASE}/pointer_upper.png")`;
export const IMAGE_TIME_ICON_DUSK = `url("${IMAGE_BASE}/time_icon_dusk.png")`;
export const IMAGE_TIME_ICON_MORNING = `url("${IMAGE_BASE}/time_icon_morning.png")`;
export const IMAGE_TIME_ICON_NIGHT = `url("${IMAGE_BASE}/time_icon_night.png")`;
export const IMAGE_TIME_ICON_NOON = `url("${IMAGE_BASE}/time_icon_noon.png")`;
export const IMAGE_TIME_INFO_ARROW = `url("${IMAGE_BASE}/time_info_arrow.png")`;
export const IMAGE_TIME_ZONE_COLOR = `${IMAGE_BASE}/time_zone_color.png`;
/** 是否正在自动旋转 */
export const isAutoRotating = ref(false);
/** 是否时间少于 30 分钟 */
export const isTimeTooEarly = ref(false);
/** 是否时间到达上限 */
export const isTimeExceeded = ref(false);
/** 当前时 */
export const timeCurrHour = ref('00');
/** 当前分 */
export const timeCurrMinute = ref('00');
/** 当前时分 */
export const timeCurrValue = ref(0);
/** 时间差(动态)*/
export const timeDiffLabel = ref('');
/** 时间差(固定)*/
export const timeDiffLabelStill = ref('');
/** 新的时 */
export const timeNewHour = ref('00');
/** 新的分 */
export const timeNewMinute = ref('00');
/** 新的时分 */
export const timeNewValue = ref(0);

View File

@@ -0,0 +1,222 @@
<template>
<div
ref="selfRef"
class="tool-detail-page"
:class="{
'has-radius': !fullscreen.isFullscreen.value,
'is-dark-color': isDarkColor,
'is-faded': isFaded,
'is-on': wakeLock.isActive.value,
}"
:style="{
'--bg-color': bgColor,
}"
@click="isFaded = false"
>
<div class="controls">
<div class="title">开关 / Switch</div>
<n-switch
:value="wakeLock.isActive.value"
size="medium"
:round="false"
@update:value="handleToggleWakeLock"
>
<template #checked>开启</template>
<template #unchecked>关闭</template>
</n-switch>
<div class="title">背景颜色 / Background Color</div>
<n-color-picker
v-model:value="bgColor"
size="medium"
:modes="['hex']"
:show-preview="true"
:swatches="[
'#000000',
'#252525',
'#505050',
'#808080',
'#FFFFFF',
]"
/>
<div
v-show="fullscreen.isSupported"
class="title"
>切换全屏 / Toggle Fullscreen</div>
<n-switch
v-show="fullscreen.isSupported"
size="medium"
:round="false"
:value="fullscreen.isFullscreen.value"
@update:value="handleToggleFullscreen"
>
<template #checked>开启</template>
<template #unchecked>关闭</template>
</n-switch>
<div class="title">隐藏界面 / Hide UI</div>
<n-switch
v-model:value="isFaded"
size="medium"
:round="false"
@click.stop
>
<template #checked>隐藏</template>
<template #unchecked>显示</template>
</n-switch>
</div>
</div>
</template>
<script setup>
import {
NColorPicker, NSwitch,
} from 'naive-ui';
import {
computed, ref, onBeforeUnmount,
} from 'vue';
import {
useFullscreen, useWakeLock,
} from '@vueuse/core';
import {
$message,
} from '@/assets/js/naive-ui';
import {
colorHexToGrayLevel,
} from '@/assets/js/utils';
/** 背景颜色 */
const bgColor = ref('#505050');
/** 是否为深色 */
const isDarkColor = computed(() => {
let color = bgColor.value;
let level = colorHexToGrayLevel(color);
return level < 192;
});
/** 是否隐藏内容 */
const isFaded = ref(false);
/**
* @desc 自身元素 ref
* @type {VueRef<HTMLElement>}
*/
const selfRef = ref(null);
/** 全屏控制 */
const fullscreen = useFullscreen(selfRef, {
autoExit: true,
});
/** 唤醒锁 */
const wakeLock = useWakeLock();
/** 处理切换全屏 */
function handleToggleFullscreen() {
fullscreen.toggle().then(() => {
if (fullscreen.isFullscreen.value) {
$message.success('进入全屏');
} else {
$message.success('退出全屏');
}
}).catch((error) => {
console.error('切换全屏失败:',);
console.error(error);
$message.error('切换全屏失败');
});
}
/** 处理切换唤醒锁 */
function handleToggleWakeLock(isActive = false) {
if (!wakeLock.isSupported) {
$message.warning('当前浏览器不支持该功能');
return Promise.resolve(false);
}
if (isActive) {
return wakeLock.request('screen').then(() => {
$message.success('开启');
}).catch((error) => {
console.error('请求唤醒锁失败:',);
console.error(error);
$message.error('请求唤醒锁失败');
});
} else {
return wakeLock.release().then(() => {
$message.success('关闭');
}).catch((error) => {
console.error('释放唤醒锁失败:',);
console.error(error);
$message.error('释放唤醒锁失败');
});
}
}
onBeforeUnmount(() => {
if (wakeLock.isActive.value) {
handleToggleWakeLock(false);
}
});
</script>
<style lang="less" scoped>
.tool-detail-page {
display: flex;
background-color: inherit;
transition: all 0.25s;
user-select: none;
&.is-faded > * {
opacity: 0;
}
&.is-on {
background-color: var(--bg-color);
&.is-dark-color {
color: #FFF;
}
}
> * {
transition: opacity 0.25s;
}
}
.controls {
margin: auto;
padding-bottom: 48px;
text-align: center;
}
.title {
margin-top: 48px;
margin-bottom: 16px;
font-size: 24px;
}
:deep(.n-color-picker) {
width: 36px;
}
:deep(.n-color-picker-trigger__value) {
color: transparent !important;
}
</style>

View File

@@ -0,0 +1,9 @@
<template>
<div class="tool-detail-page"></div>
</template>
<script setup>
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,9 @@
<template>
<div class="tool-detail-page"></div>
</template>
<script setup>
</script>
<style lang="less" scoped>
</style>

View File

@@ -1,18 +1,209 @@
<template>
<div class="toolbox-view flex-col">
<div class="app-view-header">
<span>{{ TOOLBOX_MODULE_TITLE }}</span>
<!-- 返回上一级 -->
<n-button
v-show="isToolDetail"
class="back-button"
:text="true"
@click="handleCloseTool"
>
<span class="mdi mdi-arrow-left"></span>
</n-button>
<!-- 标题 -->
<span>{{ routeTitle }}</span>
</div>
<div class="app-view-content is-transparent">
<!-- 工具列表 -->
<div class="tool-list">
<n-collapse
:default-expanded-names="toolList.map(item => item.id)"
>
<!-- 分类项 -->
<n-collapse-item
v-for="categoryItem in toolList"
v-show="categoryItem.enabled"
:key="categoryItem.id"
:name="categoryItem.id"
:title="categoryItem.title"
>
<!-- 工具项 -->
<div
v-for="toolItem in categoryItem.items"
v-show="toolItem.enabled"
:key="toolItem.id"
class="tool-item shadow-1"
@click="handleOpenTool(toolItem)"
>
<div class="item-header">
<n-tooltip placement="top-start" trigger="hover">
<template #trigger>
<span :class="['item-icon', toolItem.iconClass || 'mdi mdi-package-variant-closed']"></span>
</template>
<div>
<div>创建:{{ toolItem.createdAt }}</div>
<div>更新:{{ toolItem.updatedAt }}</div>
<div>版本:{{ toolItem.version }}</div>
</div>
</n-tooltip>
</div>
<div class="item-body">
<div class="item-title">{{ toolItem.title }}</div>
<n-ellipsis
class="item-desc"
:line-clamp="2"
:tooltip="{ placement: 'bottom-start' }"
>{{ toolItem.desc }}</n-ellipsis>
</div>
</div>
</n-collapse-item>
</n-collapse>
</div>
<!-- 工具页面 -->
<div
v-show="isToolDetail"
class="tool-detail-wrapper"
>
<router-view></router-view>
</div>
</div>
</div>
</template>
<script setup>
import {
TOOLBOX_MODULE_TITLE,
} from '@/config/modules';
NButton, NCollapse, NCollapseItem, NEllipsis, NTooltip,
} from 'naive-ui';
import {
computed,
} from 'vue';
import {
useRoute, useRouter,
} from 'vue-router';
import {
toolList,
} from '@/assets/js/toolbox-data';
/** 是否为工具页面 */
const isToolDetail = computed(() => {
return route.meta.isToolDetail;
});
/** 路由 */
const route = useRoute();
/** 路由 */
const router = useRouter();
/** 页面标题 */
const routeTitle = computed(() => {
return route.meta.title;
});
/** 关闭工具 */
function handleCloseTool() {
return router.push({
name: 'ToolboxView',
});
}
/**
* @description 打开工具
* @param {ToolboxItem} data
*/
function handleOpenTool(data) {
return router.push({
name: `Toolbox/${data.component}`,
});
}
</script>
<style lang="less" scoped>
.back-button {
margin-right: 0.5em;
font-size: 24px;
}
.tool-list {
width: 100%;
height: 100%;
overflow-y: auto;
}
.tool-item {
display: inline-block;
margin: 16px;
padding: 16px;
vertical-align: middle;
width: 256px;
height: 132px;
border-radius: var(--border-radius);
outline: 2px solid transparent;
background-color: #FFF;
font-size: 16px;
line-height: 1;
transition: outline 0.25s;
cursor: pointer;
&:hover {
outline-color: var(--color-primary);
}
.item-icon {
font-size: 32px;
opacity: 0.75;
}
.item-title {
margin: 8px 0;
font-size: 18px;
}
:deep(.item-desc) {
font-size: 14px;
line-height: 1.2;
opacity: 0.75;
}
}
.tool-detail-wrapper {
position: absolute;
left: 0;
top: 0;
padding: 20px;
width: 100%;
height: 100%;
background-color: #FFF;
:deep(.tool-detail-page) {
width: 100%;
height: 100%;
overflow-y: auto;
&.has-radius {
border-radius: 8px;
}
> .n-card:not(:first-child) {
margin-top: 20px;
}
.form-no-feedback .n-form-item-feedback-wrapper,
.form-item-no-feedback .n-form-item-feedback-wrapper {
min-height: 10px;
}
}
}
</style>

47
types/web.d.ts vendored
View File

@@ -4,7 +4,7 @@ import type {
} from 'vue';
import type {
RouteLocationRaw,
RouteLocationRaw, RouteRecordRaw,
} from 'vue-router';
declare global {
@@ -33,6 +33,40 @@ declare global {
_key?: string;
};
/** 工具箱分类 */
interface ToolboxCategory {
/** 分类唯一 ID */
id: string;
/** 分类名称 */
title: string;
/** 是否启用 */
enabled: boolean;
/** 工具列表 */
items: ToolboxItem[];
}
/** 工具箱工具信息 */
interface ToolboxItem {
/** 工具唯一 ID */
id: string;
/** 工具名称 */
title: string;
/** 工具简介 */
desc: string;
/** 图标 */
iconClass: string;
/** 组件路径 */
component: string;
/** 创建日期 */
createdAt: string;
/** 更新日期 */
updatedAt: string;
/** 版本号 */
version: string;
/** 是否启用 */
enabled: boolean;
}
// window
interface Window {
@@ -54,14 +88,25 @@ declare global {
// Vue Router
type VueRouteLocationRaw = RouteLocationRaw;
type VueRouteRecordRaw = RouteRecordRaw;
}
declare module 'vue-router' {
interface RouteMeta {
/** 主界面侧边栏图标 class */
iconClass?: string;
/** 是否为工具页面 */
isToolDetail?: boolean;
/** 是否在主界面侧边栏显示 */
showInAside?: boolean;
/** 页面标题 */
title?: string;
}
}

View File

@@ -14,9 +14,15 @@ export default defineConfig({
build: {
assetsInlineLimit: 0,
},
esbuild: {
supported: {
bigint: true,
},
},
envPrefix: 'V_ENV_',
plugins: [
legacy({
polyfills: false,
renderLegacyChunks: true,
renderModernChunks: true,
targets: ['defaults', 'not IE 11'],