update at 2026-03-16 09:00:35

This commit is contained in:
douboer@gmail.com
2026-03-16 09:00:35 +08:00
parent 4b280073d4
commit 3d8dba12aa
24 changed files with 974 additions and 233 deletions

View File

@@ -18,6 +18,7 @@ trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT INT TERM
sleep 1
/usr/bin/swift "$CALENDAR_DIR/scripts/export-kindle-background.swift" "$URL" "$OUT_PNG" "$OUT_REGION"
node "$CALENDAR_DIR/scripts/generate-dashboard-manifest.mjs" >/dev/null
cat "$OUT_REGION"

View File

@@ -2,6 +2,8 @@
import AppKit
import Foundation
import ImageIO
import UniformTypeIdentifiers
import WebKit
enum ExportError: Error, CustomStringConvertible {
@@ -29,7 +31,8 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
private let pngOutputURL: URL
private let regionOutputURL: URL
private let completion: (Result<Void, Error>) -> Void
private let targetSize = CGSize(width: 1024, height: 600)
// Kindle Voyage
private let targetSize = CGSize(width: 1072, height: 1448)
private lazy var window: NSWindow = {
let window = NSWindow(
@@ -129,16 +132,52 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
image.draw(in: NSRect(origin: .zero, size: targetSize))
normalizedImage.unlockFocus()
guard
let tiffRepresentation = normalizedImage.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffRepresentation),
let pngData = bitmap.representation(using: .png, properties: [:])
else {
guard let sourceCGImage = normalizedImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
throw ExportError.pngEncodingFailed(url.path)
}
let width = Int(targetSize.width)
let height = Int(targetSize.height)
let colorSpace = CGColorSpaceCreateDeviceGray()
// 8-bit PNG
guard let context = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.none.rawValue
) else {
throw ExportError.pngEncodingFailed(url.path)
}
context.setFillColor(gray: 1, alpha: 1)
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
context.interpolationQuality = .high
context.draw(sourceCGImage, in: CGRect(x: 0, y: 0, width: width, height: height))
guard let grayscaleImage = context.makeImage() else {
throw ExportError.pngEncodingFailed(url.path)
}
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try pngData.write(to: url)
guard let destination = CGImageDestinationCreateWithURL(
url as CFURL,
UTType.png.identifier as CFString,
1,
nil
) else {
throw ExportError.pngEncodingFailed(url.path)
}
CGImageDestinationAddImage(destination, grayscaleImage, nil)
guard CGImageDestinationFinalize(destination) else {
throw ExportError.pngEncodingFailed(url.path)
}
}
private func saveRegion(region: [String: NSNumber]) throws {

View File

@@ -58,8 +58,7 @@ const stageStyle = computed(() => ({
:style="stageStyle"
data-clock-region="true"
>
<div v-if="mode === 'background'" class="analog-clock__placeholder" />
<template v-else>
<template v-if="mode !== 'background'">
<img class="analog-clock__face" :src="CLOCK_FACE_ASSET" alt="时钟表盘" />
<img
v-if="mode === 'full'"
@@ -88,8 +87,7 @@ const stageStyle = computed(() => ({
flex: 0 0 auto;
}
.analog-clock__face,
.analog-clock__placeholder {
.analog-clock__face {
width: 100%;
height: 100%;
}
@@ -98,15 +96,6 @@ const stageStyle = computed(() => ({
object-fit: contain;
}
.analog-clock__placeholder {
border-radius: 50%;
background:
radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.94), rgba(255, 250, 246, 0.78) 68%, rgba(239, 226, 221, 0.52));
box-shadow:
inset 0 0 0 1px rgba(214, 196, 187, 0.35),
inset 0 0 24px rgba(255, 255, 255, 0.45);
}
.analog-clock__hand {
position: absolute;
max-width: none;

View File

@@ -97,30 +97,30 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
display: grid;
grid-template-rows: auto 1fr;
height: 100%;
gap: 0.95rem;
padding: 1.28rem 1.32rem 1.12rem;
gap: 1rem;
padding: 1.28rem 1.28rem 1.16rem;
}
.calendar-card__hero {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: 0.9rem;
gap: 1rem;
}
.calendar-card__headline {
display: grid;
align-content: center;
align-content: start;
flex: 1 1 auto;
min-width: 0;
gap: 0.18rem;
gap: 0.24rem;
}
.calendar-card__headline-copy {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 0.45rem;
gap: 0.5rem;
}
.calendar-card__day {
@@ -128,33 +128,34 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
'Iowan Old Style',
'Baskerville',
serif;
font-size: 5.7rem;
font-size: 6.9rem;
line-height: 0.88;
letter-spacing: -0.08em;
color: #111111;
color: #000000;
}
.calendar-card__lunar-day,
.calendar-card__weekday {
margin: 0;
font-size: 1.48rem;
font-size: 1.88rem;
line-height: 1.02;
color: #232323;
color: #000000;
white-space: nowrap;
}
.calendar-card__weekday {
font-size: 1.54rem;
font-size: 1.88rem;
}
.calendar-card__panel {
display: grid;
grid-template-rows: auto 1fr;
gap: 0.45rem;
gap: 0.55rem;
min-height: 0;
padding: 0.74rem 0.9rem 0.84rem;
border-radius: 1.1rem;
background: linear-gradient(180deg, rgba(250, 237, 238, 0.58), rgba(247, 240, 239, 0.86));
padding: 0.88rem 0.94rem 0.94rem;
border-radius: 1.25rem;
border: 2px solid var(--frame-stroke);
background: #ffffff;
}
.calendar-card__panel-header {
@@ -176,8 +177,8 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
.calendar-card__panel-subtitle {
margin-top: 0.2rem;
font-size: 0.76rem;
color: #7a6a61;
font-size: 0.84rem;
color: #000000;
}
.calendar-card__badges {
@@ -188,36 +189,21 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
}
.calendar-card__badge {
padding: 0.12rem 0.42rem;
padding: 0.14rem 0.46rem;
border-radius: 999px;
font-size: 0.66rem;
font-size: 0.72rem;
line-height: 1.2;
border: 1px solid currentColor;
background: rgba(255, 255, 255, 0.66);
}
.calendar-card__badge--holiday {
color: #8b4a20;
}
.calendar-card__badge--workday {
color: #7c5d2f;
}
.calendar-card__badge--festival {
color: #67503d;
}
.calendar-card__badge--term {
color: #4a6b6f;
color: #000000;
border: 1.5px solid var(--frame-stroke);
background: #ffffff;
}
.calendar-card__grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-auto-rows: minmax(0, 1fr);
column-gap: 0.16rem;
row-gap: 0.04rem;
column-gap: 0.18rem;
row-gap: 0.1rem;
min-height: 0;
align-content: start;
}
@@ -225,18 +211,19 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
.calendar-card__week-label {
display: grid;
place-items: center;
padding-bottom: 0.08rem;
font-size: 0.7rem;
color: #7b7b7b;
padding-bottom: 0.14rem;
font-size: 0.82rem;
color: #000000;
}
.calendar-card__cell {
display: grid;
place-items: center;
min-height: 0;
padding: 0.08rem 0 0.1rem;
border-radius: 0.78rem;
color: #3f454e;
padding: 0.16rem 0 0.18rem;
border-radius: 0.9rem;
border: 1.5px solid transparent;
color: #000000;
}
.calendar-card__cell-copy {
@@ -246,59 +233,26 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
min-width: 0;
}
.calendar-card__cell--muted {
color: #b7b0b3;
}
.calendar-card__cell--today {
background: #171717;
color: #ffffff;
}
.calendar-card__cell--holiday .calendar-card__solar {
color: #b76114;
border-color: var(--frame-stroke-strong);
}
.calendar-card__solar {
font-size: 0.92rem;
font-size: 0.98rem;
line-height: 1.05;
}
.calendar-card__sub {
max-width: 100%;
overflow: hidden;
font-size: 0.42rem;
font-size: 0.84rem;
line-height: 1;
color: #8b8b8b;
color: #000000;
text-overflow: ellipsis;
white-space: nowrap;
}
.calendar-card__sub--holiday {
color: #b76114;
}
.calendar-card__sub--workday {
color: #7c5d2f;
}
.calendar-card__sub--festival {
color: #67503d;
}
.calendar-card__sub--term {
color: #4a6b6f;
}
.calendar-card__sub--lunar {
color: #8b8b8b;
}
.calendar-card__cell--muted .calendar-card__sub {
color: #c3bbbe;
}
.calendar-card__cell--today .calendar-card__sub {
color: rgba(255, 255, 255, 0.84);
.calendar-card__cell--muted {
border-color: rgba(139, 107, 71, 0.35);
}
</style>

View File

@@ -42,7 +42,7 @@ const quoteFontSize = computed(() => {
display: grid;
gap: 0.34rem;
padding: 0.72rem 1.02rem;
background: linear-gradient(180deg, rgba(255, 248, 230, 0.96), rgba(255, 252, 243, 0.94));
background: #ffffff;
overflow: hidden;
}
@@ -50,12 +50,13 @@ const quoteFontSize = computed(() => {
display: inline-flex;
align-items: center;
gap: 0.45rem;
color: #c75d00;
color: #000000;
}
.quote-card__icon {
width: 0.9rem;
height: 0.9rem;
filter: brightness(0) saturate(100%);
}
.quote-card__title {
@@ -66,6 +67,6 @@ const quoteFontSize = computed(() => {
.quote-card__content {
margin: 0;
line-height: 1.34;
color: #292929;
color: #000000;
}
</style>

View File

@@ -2,6 +2,13 @@
import { computed } from 'vue';
import WeatherGlyph from './WeatherGlyph.vue';
import {
HUMIDITY_ICON_ASSET,
SUNRISE_ICON_ASSET,
SUNSET_ICON_ASSET,
VISIBILITY_ICON_ASSET,
WIND_SPEED_ICON_ASSET,
} from '@/lib/icon-assets';
import type { ForecastDay, WeatherSnapshot } from '@/lib/weather';
const props = defineProps<{
@@ -54,11 +61,13 @@ const metrics = computed(() => {
label: '日出',
value: props.weather.sunrise,
accent: 'metric-pill--sunrise',
icon: SUNRISE_ICON_ASSET,
},
{
label: '日落',
value: props.weather.sunset,
accent: 'metric-pill--sunset',
icon: SUNSET_ICON_ASSET,
},
{
label: '空气质量',
@@ -67,11 +76,13 @@ const metrics = computed(() => {
? '暂无'
: `${props.weather.aqi}${props.weather.aqiLabel}`,
accent: 'metric-pill--air',
icon: null,
},
{
label: '能见度',
value: `${props.weather.visibilityKm} km`,
accent: 'metric-pill--visibility',
icon: VISIBILITY_ICON_ASSET,
},
] as const;
});
@@ -115,11 +126,11 @@ function forecastKind(day: ForecastDay) {
<div class="weather-card__facts">
<div class="weather-card__fact">
<span class="weather-card__fact-dot weather-card__fact-dot--humidity" />
<img class="weather-card__fact-icon" :src="HUMIDITY_ICON_ASSET" alt="" aria-hidden="true" />
<span>湿度 {{ weather.humidity }}%</span>
</div>
<div class="weather-card__fact">
<span class="weather-card__fact-dot weather-card__fact-dot--wind" />
<img class="weather-card__fact-icon" :src="WIND_SPEED_ICON_ASSET" alt="" aria-hidden="true" />
<span>风速 {{ weather.windSpeed }} km/h</span>
</div>
</div>
@@ -144,7 +155,14 @@ function forecastKind(day: ForecastDay) {
:class="['metric-pill', metric.accent]"
>
<div class="metric-pill__label">
<span class="metric-pill__dot" />
<img
v-if="metric.icon"
class="metric-pill__icon"
:src="metric.icon"
alt=""
aria-hidden="true"
/>
<span v-else class="metric-pill__dot" />
<span>{{ metric.label }}</span>
</div>
<p class="metric-pill__value">{{ metric.value }}</p>
@@ -159,8 +177,8 @@ function forecastKind(day: ForecastDay) {
grid-template-rows: auto minmax(0, 1.15fr) minmax(0, 0.9fr) minmax(0, 1fr);
align-content: stretch;
height: 100%;
gap: 0.65rem;
padding: 1.05rem 1.1rem 0.92rem;
gap: 0.72rem;
padding: 1.08rem 1.12rem 0.98rem;
overflow: hidden;
}
@@ -176,16 +194,16 @@ function forecastKind(day: ForecastDay) {
}
.weather-card__title {
font-size: 2.2rem;
font-size: 2.16rem;
font-weight: 700;
color: #111111;
color: #000000;
}
.weather-card__subtitle {
margin-top: 0.14rem;
font-size: 1.18rem;
font-size: 1.12rem;
line-height: 1.08;
color: #707070;
color: #000000;
}
.weather-card__hero {
@@ -196,13 +214,14 @@ function forecastKind(day: ForecastDay) {
min-height: 0;
padding: 0.88rem 0.94rem;
border-radius: 1rem;
background: linear-gradient(180deg, #dfeaf8, #d7e4f6);
border: 2px solid var(--frame-stroke);
background: #ffffff;
}
.weather-card__hero--placeholder {
justify-content: center;
min-height: 5.75rem;
color: #617288;
color: #000000;
}
.weather-card__hero-main {
@@ -215,14 +234,14 @@ function forecastKind(day: ForecastDay) {
.weather-card__temperature {
font-size: 2.8rem;
line-height: 0.94;
color: #111111;
color: #000000;
}
.weather-card__condition {
margin-top: 0.18rem;
font-size: 1.36rem;
line-height: 1.05;
color: #2c3641;
color: #000000;
}
.weather-card__facts {
@@ -237,13 +256,21 @@ function forecastKind(day: ForecastDay) {
display: inline-flex;
align-items: flex-start;
gap: 0.45rem;
color: #617288;
color: #000000;
font-size: 1.08rem;
line-height: 1.06;
min-width: 0;
}
.weather-card__fact-dot,
.weather-card__fact-icon,
.metric-pill__icon {
width: 1rem;
height: 1rem;
object-fit: contain;
flex: 0 0 auto;
filter: brightness(0) saturate(100%);
}
.metric-pill__dot {
width: 0.42rem;
height: 0.42rem;
@@ -252,14 +279,6 @@ function forecastKind(day: ForecastDay) {
flex: 0 0 auto;
}
.weather-card__fact-dot--humidity {
color: #5c84be;
}
.weather-card__fact-dot--wind {
color: #6c7e95;
}
.weather-card__forecast {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -275,7 +294,8 @@ function forecastKind(day: ForecastDay) {
min-height: 0;
padding: 0.68rem 0.28rem;
border-radius: 1rem;
background: #f8f7f6;
border: 2px solid var(--frame-stroke);
background: #ffffff;
}
.forecast-pill__label,
@@ -286,19 +306,19 @@ function forecastKind(day: ForecastDay) {
.forecast-pill__label {
font-size: 1rem;
line-height: 1;
color: #5a5a5a;
color: #000000;
}
.forecast-pill__temp {
font-size: 1.24rem;
line-height: 1;
color: #1e1e1e;
color: #000000;
}
.forecast-pill__temp span {
margin-left: 0.12rem;
font-size: 1rem;
color: #7a7a7a;
color: #000000;
}
.weather-card__metrics {
@@ -316,6 +336,9 @@ function forecastKind(day: ForecastDay) {
min-height: 0;
padding: 0.68rem 0.74rem;
border-radius: 0.95rem;
border: 2px solid var(--frame-stroke);
background: #ffffff;
color: #000000;
}
.metric-pill__label,
@@ -334,31 +357,12 @@ function forecastKind(day: ForecastDay) {
.metric-pill__value {
font-size: 1.34rem;
line-height: 1.02;
color: #1c1c1c;
}
.metric-pill--sunrise {
background: #fff1dd;
color: #cb6800;
}
.metric-pill--sunset {
background: #efe4ff;
color: #8e42e8;
}
.metric-pill--air {
background: #dbf5df;
color: #1f9451;
}
.metric-pill--visibility {
background: #d8f4fa;
color: #17779d;
color: #000000;
}
.weather-card :deep(.glyph--large) {
width: 2.8rem;
height: 2.8rem;
filter: brightness(0) saturate(100%);
}
</style>

View File

@@ -26,6 +26,7 @@ const source = computed(() => weatherIconForKind(props.kind));
height: 1.25rem;
flex-shrink: 0;
object-fit: contain;
filter: brightness(0) saturate(100%);
}
.glyph--large {

View File

@@ -2,10 +2,15 @@ import bookIcon from '../../../assets/书摘.svg';
import cloudyIcon from '../../../assets/多云.svg';
import heavyRainIcon from '../../../assets/大雨.svg';
import snowIcon from '../../../assets/大雪.svg';
import sunriseIcon from '../../../assets/日出.svg';
import sunsetIcon from '../../../assets/日落.svg';
import lightRainIcon from '../../../assets/小雨.svg';
import nightIcon from '../../../assets/晚上.svg';
import clearIcon from '../../../assets/晴天.svg';
import humidityIcon from '../../../assets/湿度.svg';
import visibilityIcon from '../../../assets/能见度.svg';
import sleetIcon from '../../../assets/雨夹雪.svg';
import windSpeedIcon from '../../../assets/风速.svg';
export type WeatherIconKind =
| 'clear'
@@ -37,3 +42,8 @@ export function weatherIconForKind(kind: WeatherIconKind) {
}
export const QUOTE_ICON_ASSET = bookIcon;
export const HUMIDITY_ICON_ASSET = humidityIcon;
export const WIND_SPEED_ICON_ASSET = windSpeedIcon;
export const SUNRISE_ICON_ASSET = sunriseIcon;
export const SUNSET_ICON_ASSET = sunsetIcon;
export const VISIBILITY_ICON_ASSET = visibilityIcon;

View File

@@ -4,11 +4,14 @@
'PingFang SC',
'Noto Sans SC',
sans-serif;
color: #111111;
background:
radial-gradient(circle at 12% 10%, rgba(255, 255, 255, 0.9), transparent 26%),
radial-gradient(circle at 88% 18%, rgba(255, 255, 255, 0.74), transparent 22%),
linear-gradient(150deg, #fbe9e8 0%, #f2d7d7 48%, #eed6d5 100%);
color: #000000;
background: #ffffff;
--dashboard-width: 1072px;
--dashboard-height: 1448px;
--ink: #000000;
--paper: #ffffff;
--frame-stroke: #8b6b47;
--frame-stroke-strong: #6f5235;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
@@ -23,7 +26,9 @@ html,
body,
#app {
margin: 0;
width: 100%;
min-height: 100%;
background: #ffffff;
}
body {
@@ -39,45 +44,37 @@ img {
display: flex;
align-items: center;
justify-content: center;
padding: 0.75rem;
padding: 0;
background: #ffffff;
}
.page-shell--clock-face {
background: #f5f2ef;
background: #ffffff;
}
.dashboard-frame {
width: 100%;
aspect-ratio: 1024 / 600;
padding: 1rem;
border-radius: 1.9rem;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0.18)),
rgba(255, 255, 255, 0.16);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.62),
0 24px 46px rgba(99, 64, 66, 0.18);
backdrop-filter: blur(14px);
width: min(100vw, var(--dashboard-width));
aspect-ratio: 1072 / 1448;
background: var(--paper);
}
.dashboard-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 0.95fr);
grid-template-rows: minmax(0, 1fr) 72px;
gap: 1rem;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
grid-template-rows: minmax(0, 1fr) 168px;
gap: 1.25rem;
height: 100%;
padding: 1.4rem;
align-content: start;
align-items: stretch;
}
.card {
min-height: 0;
border-radius: 1.75rem;
background: rgba(255, 255, 255, 0.94);
box-shadow:
0 16px 34px rgba(92, 67, 60, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.72);
border: 1px solid rgba(117, 80, 76, 0.08);
border-radius: 2rem;
background: var(--paper);
border: 2px solid var(--frame-stroke);
box-shadow: none;
}
.clock-face-stage {
@@ -87,6 +84,12 @@ img {
aspect-ratio: 1;
}
@media (max-width: 1100px) {
.page-shell {
padding: 0.75rem;
}
}
@media (max-width: 860px) {
.dashboard-frame {
aspect-ratio: auto;
@@ -95,6 +98,7 @@ img {
.dashboard-grid {
grid-template-columns: 1fr;
grid-template-rows: none;
padding: 0.9rem;
}
.page-shell {