first commit
This commit is contained in:
parent
b0e4d0e083
commit
5ccd5bf946
5929
package-lock.json
generated
5929
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@ -9,19 +9,28 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/icons-material": "^6.4.5",
|
||||
"@mui/material": "^6.4.5",
|
||||
"@tanstack/react-query": "^5.66.9",
|
||||
"next": "15.1.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"next": "15.1.7"
|
||||
"react-use-websocket": "^4.13.0",
|
||||
"swr": "^2.3.2",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.1.7",
|
||||
"@eslint/eslintrc": "^3"
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
4343
pnpm-lock.yaml
Normal file
4343
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
30
src/app/api/news/route.ts
Normal file
30
src/app/api/news/route.ts
Normal file
@ -0,0 +1,30 @@
|
||||
// src/app/api/news/route.ts
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
const apiKey = process.env.JUHE_NEWS_KEY;
|
||||
const apiUrl = `https://v.juhe.cn/toutiao/index?type=top&key=${apiKey}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, { next: { revalidate: 600 } });
|
||||
const data = await response.json();
|
||||
|
||||
const formattedData = data.result?.data.map((item: any) => ({
|
||||
uniquekey: item.uniquekey,
|
||||
title: item.title,
|
||||
date: item.date,
|
||||
category: item.category || "头条新闻",
|
||||
author_name: item.author_name || "未知作者",
|
||||
url: item.url,
|
||||
thumbnail_pic_s: item.thumbnail_pic_s,
|
||||
is_content: item.is_content
|
||||
})) || [];
|
||||
|
||||
return NextResponse.json(formattedData);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "新闻获取失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
@ -1,21 +1,61 @@
|
||||
/* app/globals.css */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
@layer components {
|
||||
/* 自定义动画 */
|
||||
/* 优化新闻卡片动画 */
|
||||
.news-card-enter {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
.news-card-enter-active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: all 0.5s ease-out;
|
||||
}
|
||||
.news-card-exit {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.news-card-exit-active {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: all 0.5s ease-in;
|
||||
}
|
||||
/* 替换原有动画 */
|
||||
.news-transition {
|
||||
transition: transform 1s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.8s ease-in-out;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
.animate-news-vertical {
|
||||
animation: news-slide 20s linear infinite;
|
||||
}
|
||||
|
||||
.major-tick {
|
||||
@apply h-2 w-px bg-gray-300;
|
||||
}
|
||||
|
||||
.minor-tick {
|
||||
@apply h-1 w-px bg-gray-500;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
@layer utilities {
|
||||
/* 文字描边效果 */
|
||||
.text-stroke {
|
||||
-webkit-text-stroke: 1px theme("colors.gray.300");
|
||||
text-stroke: 1px theme("colors.gray.300");
|
||||
}
|
||||
|
||||
/* 渐变蒙版 */
|
||||
.fade-mask {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 1) 80%,
|
||||
rgba(0, 0, 0, 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
215
src/app/page.tsx
215
src/app/page.tsx
@ -1,101 +1,124 @@
|
||||
import Image from "next/image";
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 魔镜主页面
|
||||
* 集成所有子组件并管理主要状态
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import AnalogClock from "../components/AnalogClock";
|
||||
import CalendarGrid from "../components/CalendarGrid";
|
||||
import WeatherSection from "../components/WeatherSection";
|
||||
import NewsSection from "../components/NewsSection";
|
||||
import { generateCalendarDays } from "@/utils/calendar";
|
||||
import { WeatherData, NewsItem } from "@/types/magic-mirror";
|
||||
import VoiceAssistant from "@/components/VoiceAssistant";
|
||||
import useSWR from "swr";
|
||||
|
||||
const MagicMirror = () => {
|
||||
const [time, setTime] = useState(new Date());
|
||||
const calendarDays = useMemo(() => generateCalendarDays(), []);
|
||||
|
||||
// 天气示例数据
|
||||
const weatherData: WeatherData = {
|
||||
temp: 24,
|
||||
feelsLike: 26,
|
||||
condition: "晴",
|
||||
sunrise: "06:12",
|
||||
sunset: "18:34",
|
||||
windSpeed: 5,
|
||||
windDirection: "东南风",
|
||||
uvIndex: "中等",
|
||||
forecast: [
|
||||
{ day: "周二", low: 18, high: 26, condition: "☀️" },
|
||||
{ day: "周三", low: 20, high: 28, condition: "⛅" },
|
||||
{ day: "周四", low: 19, high: 27, condition: "🌤️" },
|
||||
{ day: "周五", low: 17, high: 25, condition: "☀️" },
|
||||
{ day: "周六", low: 16, high: 24, condition: "🌧️" },
|
||||
],
|
||||
};
|
||||
|
||||
// 新闻数据
|
||||
// 替换原有的newsItems定义部分:
|
||||
const { data: newsItems = [], error: newsError } = useSWR<NewsItem[]>(
|
||||
"/api/news",
|
||||
(url: string) => fetch(url).then((res) => res.json())
|
||||
);
|
||||
|
||||
// 时间更新
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setTime(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// 生成问候语
|
||||
const greeting = useMemo(() => {
|
||||
const hours = time.getHours();
|
||||
if (hours < 5) return "夜深了";
|
||||
if (hours < 12) return "早上好";
|
||||
if (hours < 18) return "下午好";
|
||||
return "晚上好";
|
||||
}, [time]);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
||||
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
||||
<li className="mb-2">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li>Save and see your changes instantly.</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
<div className="min-h-screen bg-black text-gray-100 font-sans antialiased overflow-hidden">
|
||||
{/* 左上角时间模块 */}
|
||||
<div className="absolute top-8 left-8 flex items-start gap-8">
|
||||
{/* 时间日期模块 */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-2xl font-light">
|
||||
{time.toLocaleDateString("zh-CN", { weekday: "long" })}
|
||||
</div>
|
||||
<div className="text-gray-400 text-sm">
|
||||
{time.toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="text-5xl font-light">
|
||||
{time.toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})}
|
||||
</div>
|
||||
<div className="text-gray-400 mb-5">
|
||||
{time.getSeconds().toString().padStart(2, "0")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
<AnalogClock time={time} />
|
||||
</div>
|
||||
|
||||
{/* 日历模块 */}
|
||||
<div className="absolute top-48 left-8 w-64">
|
||||
<div className="mb-4 text-gray-300 text-sm">
|
||||
{time.toLocaleDateString("zh-CN", { month: "long", year: "numeric" })}
|
||||
</div>
|
||||
<CalendarGrid days={calendarDays} />
|
||||
</div>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className="absolute left-8 w-64 top-[420px] border-t border-white/10" />
|
||||
|
||||
{/* 待办事项 */}
|
||||
<div className="absolute left-8 w-64 top-[460px] space-y-2 font-light">
|
||||
<div className="text-gray-400 text-sm">待办事项</div>
|
||||
<div className="text-gray-300 space-y-1">
|
||||
<div>• 项目会议准备(10:00)</div>
|
||||
<div>• 技术方案评审</div>
|
||||
<div>• 客户需求确认</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WeatherSection data={weatherData} />
|
||||
<NewsSection items={newsItems} />
|
||||
<VoiceAssistant greeting={greeting} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default MagicMirror;
|
||||
|
66
src/components/AnalogClock.tsx
Normal file
66
src/components/AnalogClock.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 模拟时钟组件
|
||||
* 显示带有指针的拟物化时钟
|
||||
*/
|
||||
|
||||
import { FC } from "react";
|
||||
|
||||
interface AnalogClockProps {
|
||||
time: Date;
|
||||
}
|
||||
|
||||
const AnalogClock: FC<AnalogClockProps> = ({ time }) => {
|
||||
const hours = time.getHours() % 12;
|
||||
const minutes = time.getMinutes();
|
||||
const seconds = time.getSeconds();
|
||||
|
||||
return (
|
||||
<div className="w-32 h-32 relative">
|
||||
{/* 时钟刻度 */}
|
||||
{Array.from({ length: 60 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 origin-center ${
|
||||
i % 5 === 0 ? "h-2 w-px bg-gray-300" : "h-1 w-px bg-gray-500"
|
||||
}`}
|
||||
style={{
|
||||
transform: `rotate(${i * 6}deg) translateY(-55px)`,
|
||||
transformOrigin: "center",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 时针 */}
|
||||
<div
|
||||
className="absolute left-1/2 bottom-1/2 bg-gray-300 w-0.5 h-8 -ml-px origin-bottom"
|
||||
style={{
|
||||
transform: `rotate(${hours * 30 + minutes * 0.5}deg)`,
|
||||
bottom: "50%",
|
||||
}}
|
||||
/>
|
||||
{/* 分针 */}
|
||||
<div
|
||||
className="absolute left-1/2 bottom-1/2 bg-gray-300 w-0.5 h-10 -ml-px origin-bottom"
|
||||
style={{
|
||||
transform: `rotate(${minutes * 6}deg)`,
|
||||
bottom: "50%",
|
||||
}}
|
||||
/>
|
||||
{/* 秒针 */}
|
||||
<div
|
||||
className="absolute left-1/2 bottom-1/2 bg-gray-400 w-px h-12 -ml-px origin-bottom"
|
||||
style={{
|
||||
transform: `rotate(${seconds * 6}deg)`,
|
||||
bottom: "50%",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 中心点 */}
|
||||
<div className="absolute left-1/2 top-1/2 w-1.5 h-1.5 bg-gray-300 rounded-full -translate-x-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalogClock;
|
30
src/components/CalendarGrid.tsx
Normal file
30
src/components/CalendarGrid.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 日历网格组件
|
||||
* 显示当月日期网格布局
|
||||
*/
|
||||
|
||||
import { FC } from "react";
|
||||
import { CalendarDay } from "../types/magic-mirror";
|
||||
|
||||
interface CalendarGridProps {
|
||||
days: CalendarDay[];
|
||||
}
|
||||
|
||||
const CalendarGrid: FC<CalendarGridProps> = ({ days }) => (
|
||||
<div className="grid grid-cols-7 gap-1 text-sm">
|
||||
{days.map((day, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`text-center p-1 rounded ${
|
||||
day.isCurrent ? "bg-white/20" : ""
|
||||
} ${day.isEmpty ? "opacity-20" : "text-gray-300"}`}
|
||||
>
|
||||
{!day.isEmpty && day.day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default CalendarGrid;
|
96
src/components/NewsSection.tsx
Normal file
96
src/components/NewsSection.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
// src/components/NewsSection.tsx
|
||||
"use client";
|
||||
|
||||
import { FC, useState, useEffect } from "react";
|
||||
import { NewsItem } from "../types/magic-mirror";
|
||||
|
||||
const SCROLL_INTERVAL = 8000;
|
||||
|
||||
const NewsSection: FC<{ items: NewsItem[] }> = ({ items }) => {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length <= 1) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (!isHovered) setActiveIndex(prev => (prev + 1) % items.length);
|
||||
}, SCROLL_INTERVAL);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [items.length, isHovered]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 w-[800px] h-20 flex items-center justify-center">
|
||||
<div className="text-gray-400 text-sm">正在加载最新新闻...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-8 left-1/2 -translate-x-1/2 w-[800px] h-20 overflow-hidden"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="relative h-full">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.uniquekey}
|
||||
className="absolute inset-0 transition-all duration-500 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||
style={{
|
||||
transform: `translateY(${(index - activeIndex) * 100}%)`,
|
||||
opacity: index === activeIndex ? 1 : 0,
|
||||
pointerEvents: index === activeIndex ? 'auto' : 'none'
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="h-full px-6 flex flex-col justify-center items-center text-center hover:bg-white/5 transition-colors group space-y-1.5"
|
||||
>
|
||||
{/* 信息行 - 添加时间显示 */}
|
||||
<div className="flex items-center text-gray-400/80 text-sm space-x-2">
|
||||
<span>{formatNewsDate(item.date)}</span>
|
||||
<span className="text-gray-500">·</span>
|
||||
<span>{formatNewsTime(item.date)}</span>
|
||||
<span className="text-gray-500">·</span>
|
||||
<span>{item.category}</span>
|
||||
{item.author_name && (
|
||||
<>
|
||||
<span className="text-gray-500">·</span>
|
||||
<span>{item.author_name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标题行 - 添加最大宽度限制 */}
|
||||
<div className="text-gray-300 text-xl font-medium tracking-wide group-hover:text-white transition-colors max-w-[90%]">
|
||||
{item.title}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 更新日期时间格式化函数
|
||||
const formatNewsDate = (datetime?: string) => {
|
||||
if (!datetime) return "";
|
||||
const [datePart, timePart] = datetime.split(" ");
|
||||
const [year, month, day] = datePart.split("-");
|
||||
return `${parseInt(month)}月${parseInt(day)}日`;
|
||||
};
|
||||
|
||||
const formatNewsTime = (datetime?: string) => {
|
||||
if (!datetime) return "";
|
||||
const timePart = datetime.split(" ")[1] || "";
|
||||
const [hours, minutes] = timePart.split(":");
|
||||
return `${hours}时${minutes}分`;
|
||||
};
|
||||
|
||||
export default NewsSection;
|
25
src/components/VoiceAssistant.tsx
Normal file
25
src/components/VoiceAssistant.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
// components/VoiceAssistant.tsx
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import MicIcon from "@mui/icons-material/Mic";
|
||||
|
||||
interface VoiceAssistantProps {
|
||||
greeting: string;
|
||||
}
|
||||
|
||||
const VoiceAssistant: FC<VoiceAssistantProps> = ({ greeting }) => {
|
||||
return (
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center">
|
||||
<h1 className="text-6xl font-light mb-8 drop-shadow-glow">
|
||||
{greeting},先生
|
||||
</h1>
|
||||
<div className="flex items-center justify-center gap-2 text-xl opacity-80">
|
||||
<MicIcon fontSize="inherit" />
|
||||
<span>语音助手待命中...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VoiceAssistant;
|
70
src/components/WeatherSection.tsx
Normal file
70
src/components/WeatherSection.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 天气信息组件
|
||||
* 包含当前天气和天气预报展示
|
||||
*/
|
||||
|
||||
import { FC } from "react";
|
||||
import { WeatherData } from "../types/magic-mirror";
|
||||
import WbSunnyIcon from "@mui/icons-material/WbSunny";
|
||||
|
||||
interface WeatherSectionProps {
|
||||
data: WeatherData;
|
||||
}
|
||||
|
||||
const WeatherSection: FC<WeatherSectionProps> = ({ data }) => {
|
||||
return (
|
||||
<>
|
||||
{/* 当前天气模块 */}
|
||||
<div className="absolute top-8 right-0 w-64 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="space-y-1">
|
||||
<div className="text-gray-400">风速</div>
|
||||
<div className="text-gray-300">
|
||||
{data.windSpeed}km/h {data.windDirection}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-gray-400">日出/日落</div>
|
||||
<div className="text-gray-300">
|
||||
{data.sunrise} / {data.sunset}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-4xl">☀️</div>
|
||||
<div>
|
||||
<div className="text-3xl">{data.temp}°C</div>
|
||||
<div className="text-gray-400 text-sm">体感 {data.feelsLike}°C</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-gray-400 text-sm">紫外线指数</div>
|
||||
<div className="text-gray-300">{data.uvIndex}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 天气预报模块 */}
|
||||
<div className="absolute top-64 right-8 w-64 space-y-2">
|
||||
{data.forecast.map((day, index) => (
|
||||
<div
|
||||
key={day.day}
|
||||
className="flex justify-between text-sm"
|
||||
style={{ opacity: 1 - index * 0.15 }}
|
||||
>
|
||||
<div className="text-gray-300">{day.day}</div>
|
||||
<div className="text-gray-400">{day.condition}</div>
|
||||
<div className="text-gray-300">
|
||||
{day.low}° {day.high}°
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WeatherSection;
|
38
src/types/magic-mirror.ts
Normal file
38
src/types/magic-mirror.ts
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 魔镜应用类型定义文件
|
||||
* 包含天气、新闻、日历等数据结构的类型定义
|
||||
*/
|
||||
|
||||
export type WeatherData = {
|
||||
temp: number;
|
||||
feelsLike: number;
|
||||
condition: string;
|
||||
sunrise: string;
|
||||
sunset: string;
|
||||
windSpeed: number;
|
||||
windDirection: string;
|
||||
uvIndex: string;
|
||||
forecast: {
|
||||
day: string;
|
||||
low: number;
|
||||
high: number;
|
||||
condition: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export interface NewsItem {
|
||||
uniquekey: string;
|
||||
title: string;
|
||||
date: string;
|
||||
category: string;
|
||||
author_name: string;
|
||||
url: string;
|
||||
thumbnail_pic_s?: string;
|
||||
is_content: string;
|
||||
}
|
||||
|
||||
export type CalendarDay = {
|
||||
day: number;
|
||||
isCurrent: boolean;
|
||||
isEmpty: boolean;
|
||||
};
|
23
src/utils/calendar.ts
Normal file
23
src/utils/calendar.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 日历工具函数
|
||||
* 生成当月日历数据
|
||||
*/
|
||||
|
||||
import { CalendarDay } from "@/types/magic-mirror";
|
||||
|
||||
export const generateCalendarDays = (): CalendarDay[] => {
|
||||
const date = new Date();
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
|
||||
return Array.from({ length: 42 }, (_, i) => {
|
||||
const day = i - firstDay + 1;
|
||||
return {
|
||||
day: day > 0 && day <= daysInMonth ? day : 0,
|
||||
isCurrent: day === date.getDate(),
|
||||
isEmpty: day <= 0 || day > daysInMonth,
|
||||
};
|
||||
});
|
||||
};
|
Loading…
Reference in New Issue
Block a user