first commit

This commit is contained in:
298977887 2025-02-27 21:39:32 +08:00
parent b0e4d0e083
commit 5ccd5bf946
13 changed files with 4907 additions and 6043 deletions

5929
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

30
src/app/api/news/route.ts Normal file
View 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 }
);
}
}

View File

@ -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)
);
}
}

View File

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

View 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;

View 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;

View 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;

View 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;

View 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
View 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
View 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,
};
});
};