12 Commits

13 changed files with 1778 additions and 6 deletions

View File

@@ -1,5 +1,16 @@
# 更新日志
## [3.1.2] - 2024-10-13
### Added
- `工具箱` 添加“保持亮屏”。
- `工具箱` 添加“原神时钟”。
### Changed
- `工具箱` 调整页面路由生成逻辑,不跳过未启用的工具。
## [3.1.1] - 2024-09-08
### Fixed

View File

@@ -1,7 +1,7 @@
{
"name": "frost-navigation",
"description": "Frost Navigation",
"version": "3.1.1",
"version": "3.1.2",
"type": "module",
"scripts": {
"dev": "vite",

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

@@ -195,8 +195,30 @@ export const toolList = [
{
id: 'other-tools',
title: '其他',
enabled: false,
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',
@@ -245,10 +267,10 @@ export function getToolboxRoutes() {
toolList.forEach((categoryItem) => {
categoryItem.items.forEach((toolItem) => {
// 跳过未启用的工具
if (!toolItem.enabled) {
return;
}
// // 跳过未启用的工具
// if (!toolItem.enabled) {
// return;
// }
routes.push({
path: `/toolbox-view/${toolItem.id}`,

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

@@ -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

@@ -192,6 +192,10 @@ function handleOpenTool(data) {
height: 100%;
overflow-y: auto;
&.has-radius {
border-radius: 8px;
}
> .n-card:not(:first-child) {
margin-top: 20px;
}