first commit

This commit is contained in:
douboer@gmail.com
2026-03-15 09:30:40 +08:00
commit 3d19c4d34f
145 changed files with 11623 additions and 0 deletions

12
calendar/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Calendar Dashboard</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1226
calendar/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
calendar/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "calendar",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"lunar-typescript": "^1.8.6",
"vue": "^3.5.30"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"typescript": "^5.9.3",
"vite": "^8.0.0",
"vue-tsc": "^3.2.5"
}
}

72
calendar/src/App.vue Normal file
View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import CalendarCard from '@/components/CalendarCard.vue';
import QuoteCard from '@/components/QuoteCard.vue';
import WeatherCard from '@/components/WeatherCard.vue';
import { buildCalendarModel } from '@/lib/calendar';
import { getQuoteForDate } from '@/lib/quotes';
import { fetchWeather, resolveLocation, type LocationCoordinates, type WeatherSnapshot } from '@/lib/weather';
const now = ref(new Date());
const location = ref<LocationCoordinates>({
latitude: 31.2304,
longitude: 121.4737,
label: '上海',
});
const weather = ref<WeatherSnapshot | null>(null);
const weatherStatus = ref<'idle' | 'loading' | 'ready' | 'error'>('idle');
let clockTimer = 0;
let weatherTimer = 0;
const calendarModel = computed(() => buildCalendarModel(now.value));
const quoteEntry = computed(() => getQuoteForDate(now.value));
async function refreshWeather() {
weatherStatus.value = 'loading';
try {
weather.value = await fetchWeather(location.value);
weatherStatus.value = 'ready';
} catch (error) {
console.error('获取天气失败', error);
weatherStatus.value = 'error';
}
}
onMounted(async () => {
location.value = await resolveLocation();
await refreshWeather();
clockTimer = window.setInterval(() => {
now.value = new Date();
}, 30 * 1000);
// 天气不需要秒级刷新30 分钟一次足够。
weatherTimer = window.setInterval(() => {
void refreshWeather();
}, 30 * 60 * 1000);
});
onBeforeUnmount(() => {
window.clearInterval(clockTimer);
window.clearInterval(weatherTimer);
});
</script>
<template>
<main class="page-shell">
<div class="dashboard-frame">
<div class="dashboard-grid">
<CalendarCard :model="calendarModel" />
<WeatherCard
:weather="weather"
:status="weatherStatus"
:location-label="location.label"
/>
<QuoteCard :quote="quoteEntry.text" />
</div>
</div>
</main>
</template>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { CalendarModel } from '@/lib/calendar';
const props = defineProps<{
model: CalendarModel;
}>();
const weeks = computed(() => {
const chunks: CalendarModel['cells'][] = [];
for (let index = 0; index < props.model.cells.length; index += 7) {
chunks.push(props.model.cells.slice(index, index + 7));
}
return chunks;
});
</script>
<template>
<section class="card calendar-card">
<div class="calendar-card__header">
<div class="calendar-card__day">{{ model.largeDay }}</div>
<div class="calendar-card__meta">
<p class="calendar-card__date-line">
<span>{{ model.gregorianLabel }}</span>
<span class="calendar-card__weekday">{{ model.weekdayLabel }}</span>
</p>
<p class="calendar-card__lunar">
<span>{{ model.lunarYearLabel }}</span>
<span>{{ model.lunarDayLabel }}</span>
<span v-if="model.summaryBadges.length" class="calendar-card__badges calendar-card__badges--inline">
<span
v-for="badge in model.summaryBadges"
:key="badge.label"
:class="['calendar-card__badge', `calendar-card__badge--${badge.tone}`]"
>
{{ badge.label }}
</span>
</span>
</p>
</div>
</div>
<div class="calendar-card__grid">
<div v-for="label in model.weekLabels" :key="label" class="calendar-card__week-label">
{{ label }}
</div>
<template v-for="week in weeks" :key="week[0].date.toISOString()">
<div
v-for="cell in week"
:key="cell.date.toISOString()"
:class="[
'calendar-card__cell',
{
'calendar-card__cell--muted': !cell.currentMonth,
'calendar-card__cell--today': cell.isToday,
'calendar-card__cell--holiday': cell.badges.some((badge) => badge.tone === 'holiday'),
},
]"
>
<span class="calendar-card__solar">{{ cell.day }}</span>
<span class="calendar-card__lunar-day">{{ cell.subLabel }}</span>
</div>
</template>
</div>
</section>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
quote: string;
}>();
const quoteFontSize = computed(() => {
const length = props.quote.length;
if (length > 140) {
return '1.75rem';
}
if (length > 100) {
return '1.95rem';
}
return '2.2rem';
});
</script>
<template>
<section class="card quote-card">
<p class="quote-card__content" :style="{ fontSize: quoteFontSize }">{{ quote }}</p>
</section>
</template>

View File

@@ -0,0 +1,158 @@
<script setup lang="ts">
import { computed } from 'vue';
import WeatherGlyph from './WeatherGlyph.vue';
import type { ForecastDay, WeatherSnapshot } from '@/lib/weather';
const props = defineProps<{
weather: WeatherSnapshot | null;
status: 'idle' | 'loading' | 'ready' | 'error';
locationLabel: string;
}>();
function weatherKind(code: number) {
if ([0].includes(code)) {
return 'clear';
}
if ([1, 2].includes(code)) {
return 'partly';
}
if ([3].includes(code)) {
return 'cloudy';
}
if ([45, 48].includes(code)) {
return 'fog';
}
if ([51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82].includes(code)) {
return 'rain';
}
if ([71, 73, 75, 77, 85, 86].includes(code)) {
return 'snow';
}
if ([95, 96, 99].includes(code)) {
return 'storm';
}
return 'cloudy';
}
const forecast = computed(() => props.weather?.forecast.slice(0, 5) ?? []);
const metrics = computed(() => {
if (!props.weather) {
return [];
}
return [
{
label: '日出',
value: props.weather.sunrise,
accent: 'metric--sunrise',
icon: 'sunrise',
},
{
label: '日落',
value: props.weather.sunset,
accent: 'metric--sunset',
icon: 'sunset',
},
{
label: '空气质量',
value:
props.weather.aqi === null
? '暂无'
: `${props.weather.aqi}${props.weather.aqiLabel}`,
accent: 'metric--air',
icon: 'air',
},
{
label: '能见度',
value: `${props.weather.visibilityKm} km`,
accent: 'metric--visibility',
icon: 'visibility',
},
] as const;
});
const stateLabel = computed(() => {
if (props.status === 'error') {
return '天气数据获取失败';
}
if (props.status === 'loading') {
return '天气数据加载中';
}
return props.locationLabel;
});
const currentWeatherKind = computed(() => weatherKind(props.weather?.weatherCode ?? 3));
function forecastKind(day: ForecastDay) {
return weatherKind(day.weatherCode);
}
</script>
<template>
<section class="card weather-card">
<div class="weather-card__heading">
<div>
<p class="weather-card__title">天气预报</p>
<p class="weather-card__subtitle">{{ stateLabel }}</p>
</div>
</div>
<div v-if="weather" class="weather-card__hero">
<div class="weather-card__hero-main">
<WeatherGlyph :kind="currentWeatherKind" large />
<div>
<div class="weather-card__temperature">{{ weather.temperature }}°C</div>
<div class="weather-card__condition">{{ weather.condition }}</div>
</div>
</div>
<div class="weather-card__facts">
<div class="weather-card__fact">
<WeatherGlyph kind="humidity" />
<span>湿度 {{ weather.humidity }}%</span>
</div>
<div class="weather-card__fact">
<WeatherGlyph kind="wind" />
<span>风速 {{ weather.windSpeed }} km/h</span>
</div>
</div>
</div>
<div v-else class="weather-card__hero weather-card__hero--placeholder">
<p>{{ stateLabel }}</p>
</div>
<div class="weather-card__forecast">
<article v-for="day in forecast" :key="day.label" class="forecast-pill">
<p class="forecast-pill__label">{{ day.label }}</p>
<WeatherGlyph :kind="forecastKind(day)" large />
<p class="forecast-pill__temp">{{ day.high }}°<span>{{ day.low }}°</span></p>
</article>
</div>
<div class="weather-card__metrics">
<article
v-for="metric in metrics"
:key="metric.label"
:class="['metric-pill', metric.accent]"
>
<div class="metric-pill__label">
<WeatherGlyph :kind="metric.icon" />
<span>{{ metric.label }}</span>
</div>
<p class="metric-pill__value">{{ metric.value }}</p>
</article>
</div>
</section>
</template>

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
import { computed } from 'vue';
type WeatherGlyphKind =
| 'clear'
| 'partly'
| 'cloudy'
| 'fog'
| 'rain'
| 'snow'
| 'storm'
| 'sunrise'
| 'sunset'
| 'air'
| 'visibility'
| 'humidity'
| 'wind';
const props = defineProps<{
kind: WeatherGlyphKind;
large?: boolean;
}>();
// 统一收细线宽和轮廓,让图标在墨水屏和小尺寸卡片里更精致、更稳定。
const strokeWidth = computed(() => (props.large ? 1.95 : 1.8));
</script>
<template>
<svg
:class="['glyph', { 'glyph--large': props.large }]"
viewBox="0 0 48 48"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
:stroke-width="strokeWidth"
aria-hidden="true"
>
<g v-if="props.kind === 'clear'">
<circle cx="24" cy="24" r="6.5" />
<line x1="24" y1="8.5" x2="24" y2="12.5" />
<line x1="24" y1="35.5" x2="24" y2="39.5" />
<line x1="8.5" y1="24" x2="12.5" y2="24" />
<line x1="35.5" y1="24" x2="39.5" y2="24" />
<line x1="13.1" y1="13.1" x2="15.9" y2="15.9" />
<line x1="32.1" y1="32.1" x2="34.9" y2="34.9" />
<line x1="13.1" y1="34.9" x2="15.9" y2="32.1" />
<line x1="32.1" y1="15.9" x2="34.9" y2="13.1" />
</g>
<g v-else-if="props.kind === 'partly'">
<circle cx="16.5" cy="16.5" r="5" />
<line x1="16.5" y1="7.5" x2="16.5" y2="10.5" />
<line x1="9.2" y1="16.5" x2="12.2" y2="16.5" />
<line x1="11.4" y1="11.4" x2="13.6" y2="13.6" />
<line x1="21.6" y1="11.4" x2="19.4" y2="13.6" />
<path d="M15.5 31.5h15.8a5.7 5.7 0 1 0-.5-11.3 8.4 8.4 0 0 0-15.3-1.7 6.2 6.2 0 0 0 0 13Z" />
</g>
<g v-else-if="props.kind === 'cloudy'">
<path d="M13 31.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 31.5Z" />
</g>
<g v-else-if="props.kind === 'fog'">
<path d="M13 25.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 25.5Z" />
<path d="M11 31c2 0 3-1 5-1s3 1 5 1 3-1 5-1 3 1 5 1 3-1 5-1" />
<path d="M14 36c1.7 0 2.6-.8 4.3-.8s2.6.8 4.3.8 2.6-.8 4.3-.8 2.6.8 4.3.8" />
</g>
<g v-else-if="props.kind === 'rain'">
<path d="M13 26.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 26.5Z" />
<path d="M16 31c0 1.9-1.2 3.4-2.7 3.4s-2.7-1.5-2.7-3.4c0-1.6 2.7-4.9 2.7-4.9S16 29.4 16 31Z" />
<path d="M25.4 34.2c0 1.8-1.1 3.2-2.4 3.2s-2.4-1.4-2.4-3.2c0-1.5 2.4-4.5 2.4-4.5s2.4 3 2.4 4.5Z" />
<path d="M34.8 31c0 1.9-1.2 3.4-2.7 3.4s-2.7-1.5-2.7-3.4c0-1.6 2.7-4.9 2.7-4.9s2.7 3.3 2.7 4.9Z" />
</g>
<g v-else-if="props.kind === 'snow'">
<path d="M13 26.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 26.5Z" />
<g>
<line x1="16.5" y1="30.5" x2="16.5" y2="38.5" />
<line x1="13" y1="34.5" x2="20" y2="34.5" />
<line x1="13.8" y1="31.8" x2="19.2" y2="37.2" />
<line x1="19.2" y1="31.8" x2="13.8" y2="37.2" />
</g>
<g>
<line x1="31.5" y1="30.5" x2="31.5" y2="38.5" />
<line x1="28" y1="34.5" x2="35" y2="34.5" />
<line x1="28.8" y1="31.8" x2="34.2" y2="37.2" />
<line x1="34.2" y1="31.8" x2="28.8" y2="37.2" />
</g>
</g>
<g v-else-if="props.kind === 'storm'">
<path d="M13 26.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 26.5Z" />
<path d="M24.5 29.5l-4 7h4.4l-2 8 7.6-10h-4.8l2.3-5Z" />
</g>
<g v-else-if="props.kind === 'sunrise'">
<line x1="10" y1="33" x2="38" y2="33" />
<path d="M16 33a8 8 0 0 1 16 0" />
<line x1="24" y1="10" x2="24" y2="16" />
<polyline points="21,13 24,10 27,13" />
<line x1="15" y1="26" x2="17.5" y2="23.5" />
<line x1="33" y1="26" x2="30.5" y2="23.5" />
</g>
<g v-else-if="props.kind === 'sunset'">
<line x1="10" y1="33" x2="38" y2="33" />
<path d="M16 33a8 8 0 0 1 16 0" />
<line x1="24" y1="10" x2="24" y2="16" />
<polyline points="21,13 24,16 27,13" />
<line x1="15" y1="26" x2="17.5" y2="28.5" />
<line x1="33" y1="26" x2="30.5" y2="28.5" />
</g>
<g v-else-if="props.kind === 'air'">
<path d="M10 17c4.2 0 4.2-3 8.4-3s4.2 3 8.4 3 4.2-3 8.4-3" />
<path d="M13 24c3.8 0 3.8-2.5 7.6-2.5s3.8 2.5 7.6 2.5 3.8-2.5 7.6-2.5" />
<path d="M10 31c4.2 0 4.2-3 8.4-3s4.2 3 8.4 3 4.2-3 8.4-3" />
</g>
<g v-else-if="props.kind === 'visibility'">
<path d="M6 24c3.1-4.8 8.8-8 18-8s14.9 3.2 18 8c-3.1 4.8-8.8 8-18 8S9.1 28.8 6 24Z" />
<circle cx="24" cy="24" r="4.8" />
<circle cx="24" cy="24" r="1.5" fill="currentColor" stroke="none" />
</g>
<g v-else-if="props.kind === 'humidity'">
<path d="M24 10C20.5 15 16 20.4 16 27a8 8 0 0 0 16 0c0-6.6-4.5-12-8-17Z" />
<path d="M24 30.5a4.6 4.6 0 0 0 4.4-3.1" />
</g>
<g v-else-if="props.kind === 'wind'">
<path d="M10 17h17c3.3 0 5.5-2 5.5-4.8 0-2.4-1.9-4.2-4.4-4.2-2.1 0-3.7 1.2-4.4 3" />
<path d="M10 25h22c2.8 0 4.8 1.8 4.8 4.2 0 2.3-1.8 4.1-4.1 4.1-1.9 0-3.4-.9-4.2-2.4" />
<path d="M10 33h11c2.5 0 4.3 1.6 4.3 3.8 0 2.1-1.7 3.7-3.9 3.7-1.6 0-2.9-.7-3.7-2" />
</g>
</svg>
</template>
<style scoped>
.glyph {
width: 1.12rem;
height: 1.12rem;
flex-shrink: 0;
color: currentColor;
overflow: visible;
}
.glyph--large {
width: 2.7rem;
height: 2.7rem;
}
</style>

11
calendar/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>;
export default component;
}
declare module '*.md?raw' {
const content: string;
export default content;
}

View File

@@ -0,0 +1,132 @@
import { HolidayUtil, Solar } from 'lunar-typescript';
export interface CalendarBadge {
label: string;
tone: 'holiday' | 'workday' | 'festival' | 'term';
}
export interface CalendarCell {
date: Date;
day: number;
subLabel: string;
currentMonth: boolean;
isToday: boolean;
badges: CalendarBadge[];
}
export interface CalendarModel {
largeDay: string;
gregorianLabel: string;
weekdayLabel: string;
lunarYearLabel: string;
lunarDayLabel: string;
summaryBadges: CalendarBadge[];
weekLabels: string[];
cells: CalendarCell[];
}
const WEEK_LABELS = ['日', '一', '二', '三', '四', '五', '六'];
function isSameDay(left: Date, right: Date) {
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
}
function badgeFromHoliday(dateKey: string): CalendarBadge[] {
const holiday = HolidayUtil.getHoliday(dateKey);
if (!holiday) {
return [];
}
return [
{
label: holiday.getName(),
tone: holiday.isWork() ? 'workday' : 'holiday',
},
];
}
function buildSolar(date: Date) {
return Solar.fromYmd(date.getFullYear(), date.getMonth() + 1, date.getDate());
}
function buildCellBadges(date: Date) {
const solar = buildSolar(date);
const lunar = solar.getLunar();
const solarFestivals = solar.getFestivals();
const lunarFestivals = lunar.getFestivals();
const otherFestivals = solar.getOtherFestivals();
const jieQi = lunar.getJieQi();
return [
...badgeFromHoliday(solar.toYmd()),
...solarFestivals.map((label) => ({ label, tone: 'festival' as const })),
...lunarFestivals.map((label) => ({ label, tone: 'festival' as const })),
...otherFestivals.map((label) => ({ label, tone: 'festival' as const })),
...(jieQi ? [{ label: jieQi, tone: 'term' as const }] : []),
];
}
function buildCellSubLabel(date: Date) {
const solar = buildSolar(date);
const lunar = solar.getLunar();
const badges = buildCellBadges(date);
if (badges.length > 0) {
return badges[0].label;
}
const lunarDay = lunar.getDayInChinese();
if (lunarDay === '初一') {
return lunar.getMonthInChinese();
}
return lunarDay;
}
export function buildCalendarModel(currentDate: Date): CalendarModel {
const currentYear = currentDate.getFullYear();
const currentMonth = currentDate.getMonth();
const monthStart = new Date(currentYear, currentMonth, 1);
const monthEnd = new Date(currentYear, currentMonth + 1, 0);
const gridStart = new Date(monthStart);
gridStart.setDate(monthStart.getDate() - monthStart.getDay());
const gridEnd = new Date(monthEnd);
gridEnd.setDate(monthEnd.getDate() + (6 - monthEnd.getDay()));
const cells: CalendarCell[] = [];
const cursor = new Date(gridStart);
while (cursor <= gridEnd) {
const cellDate = new Date(cursor);
cells.push({
date: cellDate,
day: cellDate.getDate(),
subLabel: buildCellSubLabel(cellDate),
badges: buildCellBadges(cellDate),
currentMonth: cellDate.getMonth() === currentMonth,
isToday: isSameDay(cellDate, currentDate),
});
cursor.setDate(cursor.getDate() + 1);
}
const solar = buildSolar(currentDate);
const lunar = solar.getLunar();
const summaryBadges = buildCellBadges(currentDate).slice(0, 3);
return {
largeDay: String(currentDate.getDate()).padStart(2, '0'),
gregorianLabel: `${currentYear}${currentMonth + 1}${currentDate.getDate()}`,
weekdayLabel: `星期${solar.getWeekInChinese()}`,
lunarYearLabel: `${lunar.getYearInGanZhi()}${lunar.getYearShengXiao()}`,
lunarDayLabel: `${lunar.getMonthInChinese()}${lunar.getDayInChinese()}`,
summaryBadges,
weekLabels: WEEK_LABELS,
cells,
};
}

View File

@@ -0,0 +1,49 @@
import quoteMarkdown from '../../../日知录.md?raw';
export interface QuoteEntry {
date: string;
text: string;
}
const QUOTE_PATTERN = /^>\s*(.+?)\s*--\s*(\d{4}-\d{2}-\d{2})$/gm;
const QUOTE_ENTRIES: QuoteEntry[] = Array.from(quoteMarkdown.matchAll(QUOTE_PATTERN)).map(
(match) => ({
text: match[1].trim(),
date: match[2],
}),
);
function pad(value: number) {
return String(value).padStart(2, '0');
}
function toMonthDay(date: Date) {
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
function toIsoDate(date: Date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
export function getQuoteForDate(date: Date) {
const isoDate = toIsoDate(date);
const monthDay = toMonthDay(date);
const exactMatch = QUOTE_ENTRIES.find((entry) => entry.date === isoDate);
if (exactMatch) {
return exactMatch;
}
const monthDayMatch = QUOTE_ENTRIES.find((entry) => entry.date.slice(5) === monthDay);
if (monthDayMatch) {
return monthDayMatch;
}
// 文件日期不覆盖全年时,回退到一个稳定的循环选择,保证每天都有内容可显示。
const startOfYear = new Date(date.getFullYear(), 0, 1);
const dayIndex = Math.floor((date.getTime() - startOfYear.getTime()) / 86_400_000);
return QUOTE_ENTRIES[(dayIndex % QUOTE_ENTRIES.length + QUOTE_ENTRIES.length) % QUOTE_ENTRIES.length];
}

203
calendar/src/lib/weather.ts Normal file
View File

@@ -0,0 +1,203 @@
export interface LocationCoordinates {
latitude: number;
longitude: number;
label: string;
}
export interface ForecastDay {
label: string;
weatherCode: number;
condition: string;
high: number;
low: number;
}
export interface WeatherSnapshot {
temperature: number;
condition: string;
weatherCode: number;
humidity: number;
windSpeed: number;
visibilityKm: number;
forecast: ForecastDay[];
sunrise: string;
sunset: string;
aqi: number | null;
aqiLabel: string;
}
const DEFAULT_LOCATION: LocationCoordinates = {
latitude: 31.2304,
longitude: 121.4737,
label: '上海',
};
const WEATHER_CODE_LABELS: Record<number, string> = {
0: '晴朗',
1: '晴间多云',
2: '多云',
3: '阴天',
45: '有雾',
48: '冻雾',
51: '毛毛雨',
53: '小雨',
55: '中雨',
56: '冻毛雨',
57: '冻毛雨',
61: '小雨',
63: '中雨',
65: '大雨',
66: '冻雨',
67: '冻雨',
71: '小雪',
73: '中雪',
75: '大雪',
77: '阵雪',
80: '阵雨',
81: '强阵雨',
82: '暴雨',
85: '阵雪',
86: '大阵雪',
95: '雷阵雨',
96: '雷暴冰雹',
99: '强雷暴',
};
function formatClock(value: string) {
const date = new Date(value);
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
function formatForecastLabel(value: string, index: number) {
if (index === 0) {
return '今天';
}
if (index === 1) {
return '明天';
}
return new Intl.DateTimeFormat('zh-CN', {
weekday: 'short',
}).format(new Date(value));
}
function aqiToLabel(aqi: number | null) {
if (aqi === null) {
return '暂无';
}
if (aqi <= 50) {
return '优';
}
if (aqi <= 100) {
return '良';
}
if (aqi <= 150) {
return '轻度';
}
if (aqi <= 200) {
return '中度';
}
if (aqi <= 300) {
return '重度';
}
return '严重';
}
export function weatherCodeToLabel(code: number) {
return WEATHER_CODE_LABELS[code] ?? '天气';
}
export async function resolveLocation(): Promise<LocationCoordinates> {
if (!('geolocation' in navigator)) {
return DEFAULT_LOCATION;
}
return new Promise((resolve) => {
navigator.geolocation.getCurrentPosition(
(position) => {
resolve({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
label: '当前位置',
});
},
() => resolve(DEFAULT_LOCATION),
{
enableHighAccuracy: false,
timeout: 5000,
maximumAge: 10 * 60 * 1000,
},
);
});
}
export async function fetchWeather(location: LocationCoordinates): Promise<WeatherSnapshot> {
const weatherParams = new URLSearchParams({
latitude: String(location.latitude),
longitude: String(location.longitude),
current: [
'temperature_2m',
'relative_humidity_2m',
'weather_code',
'wind_speed_10m',
'visibility',
].join(','),
daily: ['weather_code', 'temperature_2m_max', 'temperature_2m_min', 'sunrise', 'sunset'].join(','),
forecast_days: '5',
timezone: 'auto',
});
const airParams = new URLSearchParams({
latitude: String(location.latitude),
longitude: String(location.longitude),
current: 'us_aqi',
timezone: 'auto',
});
const [weatherResponse, airResponse] = await Promise.all([
fetch(`https://api.open-meteo.com/v1/forecast?${weatherParams.toString()}`),
fetch(`https://air-quality-api.open-meteo.com/v1/air-quality?${airParams.toString()}`),
]);
if (!weatherResponse.ok) {
throw new Error(`天气接口返回异常:${weatherResponse.status}`);
}
const weatherJson = await weatherResponse.json();
const airJson = airResponse.ok ? await airResponse.json() : null;
const aqi = airJson?.current?.us_aqi ?? null;
const daily = weatherJson.daily;
return {
temperature: Math.round(weatherJson.current.temperature_2m),
condition: weatherCodeToLabel(weatherJson.current.weather_code),
weatherCode: weatherJson.current.weather_code,
humidity: Math.round(weatherJson.current.relative_humidity_2m),
windSpeed: Math.round(weatherJson.current.wind_speed_10m),
visibilityKm: Number((weatherJson.current.visibility / 1000).toFixed(1)),
forecast: daily.time.map((value: string, index: number) => ({
label: formatForecastLabel(value, index),
weatherCode: daily.weather_code[index],
condition: weatherCodeToLabel(daily.weather_code[index]),
high: Math.round(daily.temperature_2m_max[index]),
low: Math.round(daily.temperature_2m_min[index]),
})),
sunrise: formatClock(daily.sunrise[0]),
sunset: formatClock(daily.sunset[0]),
aqi,
aqiLabel: aqiToLabel(aqi),
};
}

6
calendar/src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { createApp } from 'vue';
import App from './App.vue';
import './style.css';
createApp(App).mount('#app');

448
calendar/src/style.css Normal file
View File

@@ -0,0 +1,448 @@
:root {
font-family:
'Hiragino Sans GB',
'PingFang SC',
'Noto Sans SC',
sans-serif;
color: #0f172a;
background:
radial-gradient(circle at 18% 12%, rgba(255, 255, 255, 0.88), transparent 30%),
radial-gradient(circle at 82% 18%, rgba(241, 238, 232, 0.92), transparent 24%),
linear-gradient(160deg, #f4f1eb 0%, #ebe5dc 52%, #e4ddd4 100%);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
min-height: 100%;
}
body {
min-height: 100vh;
}
.page-shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.dashboard-frame {
width: min(100%, 1448px);
aspect-ratio: 1448 / 1072;
padding: clamp(1.2rem, 1vw + 0.9rem, 2rem);
border-radius: 2rem;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.38), rgba(255, 255, 255, 0.18)),
rgba(255, 255, 255, 0.14);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.55),
0 38px 72px rgba(64, 52, 38, 0.14);
backdrop-filter: blur(14px);
}
.dashboard-grid {
display: grid;
grid-template-columns: 1.08fr 1fr;
grid-template-rows: auto auto;
gap: clamp(1rem, 1vw + 0.75rem, 2rem);
height: 100%;
align-content: start;
align-items: stretch;
}
.card {
border-radius: 1.6rem;
background: rgba(255, 255, 255, 0.94);
box-shadow:
0 20px 36px rgba(72, 58, 44, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.65);
border: 1px solid rgba(70, 58, 46, 0.08);
}
.calendar-card,
.weather-card {
padding: clamp(1.4rem, 1.2vw + 1rem, 2rem);
min-height: 0;
height: 100%;
}
.calendar-card {
display: grid;
grid-template-rows: auto 1fr;
gap: 0.8rem;
}
.calendar-card__header {
display: flex;
align-items: center;
gap: 1.6rem;
}
.calendar-card__day {
font-family:
'Iowan Old Style',
'Baskerville',
serif;
font-size: clamp(4.4rem, 5vw, 7rem);
line-height: 0.95;
letter-spacing: -0.08em;
color: #101010;
}
.calendar-card__meta {
display: grid;
gap: 0.65rem;
}
.calendar-card__date-line,
.calendar-card__lunar {
display: flex;
gap: 1rem;
align-items: baseline;
margin: 0;
font-size: clamp(1rem, 0.7vw + 0.7rem, 1.55rem);
}
.calendar-card__weekday {
color: #556274;
}
.calendar-card__lunar {
color: #5f4b32;
flex-wrap: wrap;
}
.calendar-card__badges {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.calendar-card__badges--inline {
display: inline-flex;
align-items: center;
margin-left: 0.1rem;
}
.calendar-card__badge {
display: inline-flex;
align-items: center;
min-height: 1.6rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
border: 1px solid currentColor;
font-size: 0.76rem;
line-height: 1;
}
.calendar-card__badge--holiday {
color: #2f2b26;
background: #ece5db;
}
.calendar-card__badge--workday {
color: #68593f;
background: #f2eadb;
}
.calendar-card__badge--festival {
color: #4f4436;
background: #f5f0e8;
}
.calendar-card__badge--term {
color: #3a4a4a;
background: #e9efef;
}
.calendar-card__grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-template-rows: auto;
grid-auto-rows: minmax(0, 1fr);
gap: 0.35rem 0.3rem;
align-content: start;
height: 100%;
}
.calendar-card__week-label {
text-align: center;
color: #798395;
font-size: 0.92rem;
padding-bottom: 0.45rem;
}
.calendar-card__cell {
min-height: 0;
display: grid;
place-items: center;
align-content: center;
padding: 0.2rem 0;
border-radius: 0.8rem;
color: #304153;
transition: background-color 150ms ease;
}
.calendar-card__cell--muted {
color: #bac4d2;
}
.calendar-card__cell--today {
background: linear-gradient(180deg, #303640 0%, #1f252d 100%);
color: #ffffff;
box-shadow: 0 14px 24px rgba(31, 37, 45, 0.24);
}
.calendar-card__cell--holiday .calendar-card__solar,
.calendar-card__cell--holiday .calendar-card__lunar-day {
color: #6a4a1c;
}
.calendar-card__solar {
font-size: 1.3rem;
line-height: 1.1;
}
.calendar-card__lunar-day {
font-size: 0.68rem;
line-height: 1.2;
opacity: 0.82;
}
.weather-card {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 0.8rem;
}
.weather-card__heading {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.weather-card__title {
margin: 0;
font-size: clamp(1.5rem, 1vw + 1rem, 2rem);
font-weight: 700;
}
.weather-card__subtitle {
margin: 0.35rem 0 0;
color: #778396;
font-size: 0.95rem;
}
.weather-card__hero {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1.05rem 1.2rem;
border-radius: 1.2rem;
background: linear-gradient(180deg, #ece8e1 0%, #e2ddd4 100%);
}
.weather-card__hero--placeholder {
justify-content: center;
color: #64748b;
}
.weather-card__hero-main {
display: flex;
align-items: center;
gap: 1rem;
}
.weather-card__temperature {
font-size: clamp(2.2rem, 1.2vw + 1.6rem, 3.2rem);
line-height: 1;
font-family:
'Iowan Old Style',
'Baskerville',
serif;
}
.weather-card__condition {
margin-top: 0.35rem;
font-size: 1.15rem;
color: #243041;
}
.weather-card__facts {
display: grid;
gap: 0.45rem;
color: #475569;
}
.weather-card__fact {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.98rem;
}
.weather-card__forecast {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.65rem;
align-items: stretch;
}
.forecast-pill {
display: grid;
justify-items: center;
align-content: center;
gap: 0.45rem;
padding: 0.8rem 0.55rem;
border-radius: 1rem;
background: #f7f4ef;
border: 1px solid rgba(82, 70, 56, 0.08);
height: 100%;
}
.forecast-pill__label {
margin: 0;
color: #556274;
font-size: 0.9rem;
line-height: 1.1;
}
.forecast-pill__temp {
margin: 0;
font-size: 1rem;
font-weight: 700;
}
.forecast-pill__temp span {
margin-left: 0.15rem;
color: #7d8798;
font-weight: 500;
}
.weather-card__metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.65rem;
}
.metric-pill {
display: grid;
align-content: center;
gap: 0.45rem;
padding: 0.85rem 0.9rem 0.9rem;
border-radius: 1rem;
}
.metric-pill__label {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.88rem;
}
.metric-pill__value {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
}
.metric--sunrise {
background: linear-gradient(150deg, #f3eee5, #ebe3d6);
color: #5c4631;
}
.metric--sunset {
background: linear-gradient(150deg, #efebe6, #e5dfd6);
color: #4f463d;
}
.metric--air {
background: linear-gradient(150deg, #eef0eb, #e3e7de);
color: #354338;
}
.metric--visibility {
background: linear-gradient(150deg, #eef1f0, #e2e5e4);
color: #3b4a4d;
}
.quote-card {
grid-column: 1 / -1;
display: grid;
gap: 1.1rem;
padding: clamp(1.5rem, 1.2vw + 1rem, 2rem);
background: linear-gradient(168deg, rgba(249, 245, 236, 0.98), rgba(244, 239, 229, 0.94));
}
.quote-card__content {
margin: 0;
color: #1e293b;
line-height: 1.65;
font-family:
'Iowan Old Style',
'Baskerville',
'Noto Serif SC',
serif;
}
@media (max-width: 1080px) {
.page-shell {
padding: 0.9rem;
}
.dashboard-frame {
aspect-ratio: auto;
width: min(100%, 1448px);
}
.dashboard-grid {
grid-template-columns: 1fr;
grid-template-rows: auto;
}
.quote-card {
grid-column: auto;
}
.weather-card__forecast,
.weather-card__metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.calendar-card__header,
.weather-card__heading,
.weather-card__hero {
grid-template-columns: 1fr;
display: grid;
}
.calendar-card__header {
gap: 1rem;
}
.weather-card__forecast,
.weather-card__metrics {
grid-template-columns: 1fr 1fr;
}
.quote-card__content {
font-size: 1.45rem !important;
}
}

21
calendar/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "Node",
"paths": {
"@/*": ["src/*"]
},
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}

19
calendar/vite.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { fileURLToPath, URL } from 'node:url';
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
fs: {
// 允许直接读取仓库根目录下的《日知录》原始 Markdown。
allow: ['..'],
},
},
});