update at 2026-03-21 18:44:12

This commit is contained in:
douboer@gmail.com
2026-03-21 18:44:12 +08:00
parent f9d715157f
commit 89b1f97a6f
52 changed files with 3510 additions and 562 deletions

View File

@@ -75,152 +75,6 @@
}
}
},
{
"id": "paper",
"label": "Paper",
"preview": {
"pageBackground": "#f2eee5",
"paper": "#fcfaf4",
"panelBackground": "#fffdf8",
"frameStroke": "#7e6b57",
"frameStrokeStrong": "#5f5143",
"frameMuted": "rgba(126, 107, 87, 0.32)",
"mutedInk": "#5a5148",
"badgeFill": "#f3ede0",
"bodyFont": "'Songti SC', 'STSong', serif",
"displayFont": "'Baskerville', 'Times New Roman', 'Songti SC', serif",
"titleFont": "'Songti SC', 'STSong', serif",
"cardRadius": "1.7rem",
"panelRadius": "1.1rem"
},
"variants": {
"portrait": {
"devicePlacement": "logo_bottom",
"viewport": {
"width": 1072,
"height": 1448
},
"backgroundPath": "themes/paper/portrait/kindlebg.png",
"clock": {
"x": 347,
"y": 55,
"width": 220,
"height": 220,
"faceRadiusRatio": 0.47,
"faceStroke": 3,
"tickOuterInset": 6,
"majorTickLength": 14,
"minorTickLength": 7,
"majorTickThickness": 4,
"minorTickThickness": 2,
"hourLengthRatio": 0.48,
"minuteLengthRatio": 0.72,
"hourThickness": 9,
"minuteThickness": 5,
"centerRadius": 7
}
},
"landscape": {
"devicePlacement": "logo_right",
"viewport": {
"width": 1448,
"height": 1072
},
"backgroundPath": "themes/paper/landscape/kindlebg.png",
"clock": {
"x": 659,
"y": 57,
"width": 220,
"height": 220,
"faceRadiusRatio": 0.47,
"faceStroke": 3,
"tickOuterInset": 6,
"majorTickLength": 14,
"minorTickLength": 7,
"majorTickThickness": 4,
"minorTickThickness": 2,
"hourLengthRatio": 0.48,
"minuteLengthRatio": 0.72,
"hourThickness": 9,
"minuteThickness": 5,
"centerRadius": 7
}
}
}
},
{
"id": "classic",
"label": "Classic",
"preview": {
"pageBackground": "#ece6da",
"paper": "#ffffff",
"panelBackground": "#fefefe",
"frameStroke": "#3d352c",
"frameStrokeStrong": "#1f1a15",
"frameMuted": "rgba(61, 53, 44, 0.3)",
"mutedInk": "#3d352c",
"badgeFill": "#f3efe8",
"bodyFont": "'PingFang SC', 'Hiragino Sans GB', 'Noto Sans SC', sans-serif",
"displayFont": "'Palatino Linotype', 'Book Antiqua', 'Songti SC', serif",
"titleFont": "'Palatino Linotype', 'Book Antiqua', 'Songti SC', serif",
"cardRadius": "1.25rem",
"panelRadius": "0.92rem"
},
"variants": {
"portrait": {
"devicePlacement": "logo_bottom",
"viewport": {
"width": 1072,
"height": 1448
},
"backgroundPath": "themes/classic/portrait/kindlebg.png",
"clock": {
"x": 347,
"y": 55,
"width": 220,
"height": 220,
"faceRadiusRatio": 0.47,
"faceStroke": 3,
"tickOuterInset": 6,
"majorTickLength": 14,
"minorTickLength": 7,
"majorTickThickness": 4,
"minorTickThickness": 2,
"hourLengthRatio": 0.48,
"minuteLengthRatio": 0.72,
"hourThickness": 9,
"minuteThickness": 5,
"centerRadius": 7
}
},
"landscape": {
"devicePlacement": "logo_right",
"viewport": {
"width": 1448,
"height": 1072
},
"backgroundPath": "themes/classic/landscape/kindlebg.png",
"clock": {
"x": 659,
"y": 57,
"width": 220,
"height": 220,
"faceRadiusRatio": 0.47,
"faceStroke": 3,
"tickOuterInset": 6,
"majorTickLength": 14,
"minorTickLength": 7,
"majorTickThickness": 4,
"minorTickThickness": 2,
"hourLengthRatio": 0.48,
"minuteLengthRatio": 0.72,
"hourThickness": 9,
"minuteThickness": 5,
"centerRadius": 7
}
}
}
},
{
"id": "simple",
"label": "Simple",

View File

@@ -4,12 +4,15 @@ set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)"
CALENDAR_DIR="$ROOT_DIR/calendar"
DIST_DIR="$CALENDAR_DIR/dist"
KINDLE_BACKGROUNDS_DIR="$CALENDAR_DIR/kindle-backgrounds"
PORT=${PORT:-4173}
SWIFT_SCRIPT="$CALENDAR_DIR/scripts/export-kindle-background.swift"
THEMES_SOURCE="$CALENDAR_DIR/config/themes.json"
THEME_FILTER=""
ORIENTATION_FILTER=""
LOCATION_LAT=""
LOCATION_LON=""
print_usage() {
cat <<'EOF'
@@ -19,12 +22,15 @@ print_usage() {
选项:
--theme <theme-id> 只导出指定主题
--orientation <value> 只导出指定方向;必须和 --theme 一起使用
--location-lat <value> 导图时显式覆盖天气定位纬度
--location-lon <value> 导图时显式覆盖天气定位经度
-h, --help 查看帮助
示例:
sh scripts/export-theme-backgrounds.sh
sh scripts/export-theme-backgrounds.sh --theme simple
sh scripts/export-theme-backgrounds.sh --theme simple --orientation portrait
sh scripts/export-theme-backgrounds.sh --location-lat 30.274084 --location-lon 120.15507
EOF
}
@@ -38,6 +44,14 @@ while [ "$#" -gt 0 ]; do
shift
ORIENTATION_FILTER=${1:?"missing orientation"}
;;
--location-lat)
shift
LOCATION_LAT=${1:?"missing location latitude"}
;;
--location-lon)
shift
LOCATION_LON=${1:?"missing location longitude"}
;;
-h|--help)
print_usage
exit 0
@@ -57,6 +71,11 @@ if [ -n "$ORIENTATION_FILTER" ] && [ -z "$THEME_FILTER" ]; then
exit 1
fi
if { [ -n "$LOCATION_LAT" ] && [ -z "$LOCATION_LON" ]; } || { [ -z "$LOCATION_LAT" ] && [ -n "$LOCATION_LON" ]; }; then
echo "--location-lat 和 --location-lon 必须同时提供。" >&2
exit 1
fi
selection_output=$(
node --input-type=module -e "
import fs from 'node:fs';
@@ -143,6 +162,7 @@ fi
cd "$CALENDAR_DIR"
npm run build >/dev/null
mkdir -p "$KINDLE_BACKGROUNDS_DIR"
python3 -m http.server "$PORT" -d "$DIST_DIR" >/tmp/kindle-calendar-http.log 2>&1 &
SERVER_PID=$!
@@ -153,8 +173,15 @@ sleep 1
printf '%s\n' "$EXPORT_ITEMS" | while IFS="$(printf '\t')" read -r theme_id orientation background_path; do
out_png="$DIST_DIR/$background_path"
out_region="${out_png%.png}.clock-region.json"
flat_background_png="$KINDLE_BACKGROUNDS_DIR/${theme_id}-${orientation}.png"
url="http://127.0.0.1:$PORT/?mode=background&theme=$theme_id&orientation=$orientation"
if [ -n "$LOCATION_LAT" ] && [ -n "$LOCATION_LON" ]; then
url="${url}&location-lat=${LOCATION_LAT}&location-lon=${LOCATION_LON}"
fi
/usr/bin/swift "$SWIFT_SCRIPT" "$url" "$out_png" "$out_region" >/dev/null
# Web 侧额外维护一份扁平命名的背景图目录,方便 nginx 单独暴露给 Kindle 拉图。
# 主题 JSON 会把 background.url 指向这里,例如 /kindle-backgrounds/simple-portrait.png。
cp "$out_png" "$flat_background_png"
# 根目录的 kindlebg.png / clock-region.json 只给默认主题兜底使用。
# 定向导出其它主题时不覆盖它,避免把默认主题的运行时入口意外改掉。

View File

@@ -9,12 +9,14 @@ const clockRegionPath = path.join(distDir, 'clock-region.json');
const themesSourcePath = path.resolve(currentDir, '../config/themes.json');
const themesDistPath = path.join(distDir, 'themes.json');
const themesDir = path.join(distDir, 'themes');
const kindleBackgroundsDir = path.resolve(currentDir, '../kindle-backgrounds');
const dashboardBaseUrl = 'https://shell.biboer.cn:20001';
const themesSource = JSON.parse(fs.readFileSync(themesSourcePath, 'utf8'));
const generatedAt = new Date().toISOString();
const defaultVariant = themesSource.themes.find((theme) => theme.id === themesSource.defaultThemeId)?.variants?.[themesSource.defaultOrientation];
const defaultDeviceClock = defaultVariant ? toDeviceClock(defaultVariant, themesSource.defaultOrientation) : null;
const defaultTheme = themesSource.themes.find((theme) => theme.id === themesSource.defaultThemeId);
const defaultVariant = defaultTheme?.variants?.[themesSource.defaultOrientation];
const defaultDeviceClock = defaultVariant ? buildRuntimeClock(defaultTheme.id, themesSource.defaultOrientation, defaultVariant) : null;
const defaultClockRegion = defaultVariant
? {
x: defaultDeviceClock.x,
@@ -95,30 +97,74 @@ function toDeviceClock(variant, orientation) {
};
}
function resolveVariantClock(themeId, orientation, variant) {
const regionPath = path.join(distDir, 'themes', themeId, orientation, 'kindlebg.clock-region.json');
const exportedRegion = fs.existsSync(regionPath)
? JSON.parse(fs.readFileSync(regionPath, 'utf8'))
: null;
return {
...variant,
clock: {
...variant.clock,
...(exportedRegion
? {
x: exportedRegion.x,
y: exportedRegion.y,
width: exportedRegion.width,
height: exportedRegion.height,
}
: {}),
},
};
}
function buildRuntimeClock(themeId, orientation, variant) {
const resolvedVariant = resolveVariantClock(themeId, orientation, variant);
const hasExportedRegion =
resolvedVariant.clock.x !== variant.clock.x ||
resolvedVariant.clock.y !== variant.clock.y ||
resolvedVariant.clock.width !== variant.clock.width ||
resolvedVariant.clock.height !== variant.clock.height;
if (orientation === 'landscape' && hasExportedRegion) {
return {
...resolvedVariant.clock,
rotationDegrees: 90,
};
}
return toDeviceClock(resolvedVariant, orientation);
}
function buildThemeConfig(theme) {
return {
id: theme.id,
label: theme.label,
updatedAt: generatedAt,
variants: Object.fromEntries(
Object.entries(theme.variants).map(([orientation, variant]) => [
orientation,
{
devicePlacement: variant.devicePlacement,
background: {
path: variant.backgroundPath,
url: `${dashboardBaseUrl}/${variant.backgroundPath}`,
refreshIntervalMinutes: 120,
Object.entries(theme.variants).map(([orientation, variant]) => {
return [
orientation,
{
devicePlacement: variant.devicePlacement,
background: {
// Kindle 端统一走扁平目录,避免设备侧自己拼主题子目录规则。
path: `kindle-backgrounds/${theme.id}-${orientation}.png`,
url: `${dashboardBaseUrl}/kindle-backgrounds/${theme.id}-${orientation}.png`,
refreshIntervalMinutes: 120,
},
clock: buildRuntimeClock(theme.id, orientation, variant),
},
clock: toDeviceClock(variant, orientation),
},
]),
];
}),
),
};
}
fs.mkdirSync(distDir, { recursive: true });
fs.mkdirSync(themesDir, { recursive: true });
fs.mkdirSync(kindleBackgroundsDir, { recursive: true });
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
fs.writeFileSync(themesDistPath, `${JSON.stringify(themesIndex, null, 2)}\n`, 'utf8');

View File

@@ -36,7 +36,9 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
<p class="calendar-card__weekday">{{ model.weekdayLabel }}</p>
</div>
</div>
<AnalogClock :date="date" :mode="mode" :size="220" />
<div class="calendar-card__clock-wrap">
<AnalogClock :date="date" :mode="mode" :size="220" />
</div>
</div>
<div class="calendar-card__panel">
@@ -110,6 +112,11 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
gap: 1rem;
}
.calendar-card__clock-wrap {
width: 220px;
height: 220px;
}
.calendar-card__headline {
display: grid;
align-content: start;

View File

@@ -109,7 +109,9 @@ function isCompactCalendarLabel(label: string) {
</section>
</div>
<SimpleAnalogClock :date="date" :mode="mode" :size="480" />
<div class="simple-dashboard__clock-wrap simple-dashboard__clock-wrap--portrait">
<SimpleAnalogClock :date="date" :mode="mode" :size="480" />
</div>
</section>
<section class="simple-calendar simple-calendar--portrait">
@@ -176,7 +178,9 @@ function isCompactCalendarLabel(label: string) {
</div>
</section>
<SimpleAnalogClock :date="date" :mode="mode" :size="480" />
<div class="simple-dashboard__clock-wrap">
<SimpleAnalogClock :date="date" :mode="mode" :size="480" />
</div>
</div>
<div class="simple-dashboard__column simple-dashboard__column--right">
@@ -254,7 +258,8 @@ function isCompactCalendarLabel(label: string) {
height: 480px;
justify-content: center;
gap: 16px;
padding: 0 16px;
/* 竖版顶部区按 Figma 节点 284:7 额外下压 32px。 */
padding: 32px 16px 0;
justify-self: start;
align-self: start;
}
@@ -275,6 +280,17 @@ function isCompactCalendarLabel(label: string) {
gap: 24px;
}
.simple-dashboard__clock-wrap {
width: 480px;
height: 480px;
}
.simple-dashboard__clock-wrap--portrait {
/* simple 竖版时钟整体下移 16px和顶部信息区拉开间距。 */
padding-top: 16px;
box-sizing: border-box;
}
.simple-dashboard__summary {
min-width: 0;
}
@@ -304,6 +320,10 @@ function isCompactCalendarLabel(label: string) {
display: flex;
align-items: flex-end;
gap: 24px;
width: 100%;
box-sizing: border-box;
/* 横版日期标题组需要保留左侧 24px 对齐边距。 */
padding-left: 24px;
}
.simple-dashboard__day {
@@ -370,7 +390,10 @@ function isCompactCalendarLabel(label: string) {
align-items: center;
gap: 24px;
width: 480px;
box-sizing: border-box;
min-width: 0;
/* 横版地点行比标题再向右缩进一档。 */
padding-left: 48px;
}
.simple-dashboard__location-icon {
@@ -395,7 +418,7 @@ function isCompactCalendarLabel(label: string) {
}
.simple-dashboard__location--landscape {
width: 424px;
flex: 1 1 auto;
font-size: 56px;
line-height: 57px;
}
@@ -406,7 +429,10 @@ function isCompactCalendarLabel(label: string) {
gap: 24px;
width: 480px;
height: 99px;
box-sizing: border-box;
overflow: visible;
/* 横版天气摘要整体向右留出 24px与 Figma 左列对齐。 */
padding-left: 24px;
}
.simple-dashboard__metric {

View File

@@ -4,6 +4,7 @@ import { computed } from 'vue';
import WeatherGlyph from './WeatherGlyph.vue';
import {
HUMIDITY_ICON_ASSET,
PM25_ICON_ASSET,
SUNRISE_ICON_ASSET,
SUNSET_ICON_ASSET,
VISIBILITY_ICON_ASSET,
@@ -45,7 +46,7 @@ const metrics = computed(() => {
? '暂无'
: `${props.weather.aqi}${props.weather.aqiLabel}`,
accent: 'metric-pill--air',
icon: null,
icon: PM25_ICON_ASSET,
},
{
label: '能见度',
@@ -131,7 +132,6 @@ function forecastKind(day: ForecastDay) {
alt=""
aria-hidden="true"
/>
<span v-else class="metric-pill__dot" />
<span>{{ metric.label }}</span>
</div>
<p class="metric-pill__value">{{ metric.value }}</p>
@@ -245,14 +245,6 @@ function forecastKind(day: ForecastDay) {
filter: brightness(0) saturate(100%);
}
.metric-pill__dot {
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background: currentColor;
flex: 0 0 auto;
}
.weather-card__forecast {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));

View File

@@ -8,6 +8,7 @@ import lightRainIcon from '../../../assets/小雨.svg';
import nightIcon from '../../../assets/晚上.svg';
import clearIcon from '../../../assets/晴天.svg';
import humidityIcon from '../../../assets/湿度.svg';
import pm25Icon from '../../../assets/simple/pm25.svg';
import visibilityIcon from '../../../assets/能见度.svg';
import sleetIcon from '../../../assets/雨夹雪.svg';
import windSpeedIcon from '../../../assets/风速.svg';
@@ -79,3 +80,4 @@ 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;
export const PM25_ICON_ASSET = pm25Icon;

View File

@@ -26,6 +26,11 @@ export interface WeatherSnapshot {
aqiLabel: string;
}
interface SearchLocationOverride {
latitude: number;
longitude: number;
}
const DEFAULT_LOCATION: LocationCoordinates = {
latitude: 30.274084,
longitude: 120.15507,
@@ -193,7 +198,34 @@ async function reverseGeocodeLocation(latitude: number, longitude: number) {
}
}
export async function resolveLocation(): Promise<LocationCoordinates> {
function parseSearchLocationOverride(search: string): SearchLocationOverride | null {
const params = new URLSearchParams(search);
const latitude = Number(params.get('location-lat'));
const longitude = Number(params.get('location-lon'));
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
return {
latitude,
longitude,
};
}
export async function resolveLocation(search = window.location.search): Promise<LocationCoordinates> {
const searchOverride = parseSearchLocationOverride(search);
if (searchOverride) {
const label = await reverseGeocodeLocation(searchOverride.latitude, searchOverride.longitude);
return {
latitude: searchOverride.latitude,
longitude: searchOverride.longitude,
label: label ?? '当前位置',
};
}
if (!('geolocation' in navigator)) {
return DEFAULT_LOCATION;
}

View File

@@ -153,6 +153,86 @@ img {
grid-column: 1 / -1;
}
.page-shell--default .dashboard-grid--portrait {
/* default 纵版给底部额外留出 32px避免鸡汤卡片继续贴底溢出。 */
gap: 1rem;
padding: 1.3rem 1.3rem 32px;
grid-template-rows: minmax(0, 1fr) 232px;
}
.page-shell--default .dashboard-grid--portrait .calendar-card {
/* 顶部日历区整体压紧一点,把高度让给底部鸡汤。 */
gap: 32px;
}
.page-shell--default .dashboard-grid--portrait .calendar-card__hero {
grid-template-columns: minmax(0, 1fr) 272px;
gap: 0;
}
.page-shell--default .dashboard-grid--portrait .calendar-card__day {
font-size: calc(5.9rem * var(--theme-font-scale, 1));
}
.page-shell--default .dashboard-grid--portrait .calendar-card__lunar-day,
.page-shell--default .dashboard-grid--portrait .calendar-card__weekday {
font-size: calc(1.64rem * var(--theme-font-scale, 1));
}
.page-shell--default .dashboard-grid--portrait .calendar-card__clock-wrap {
width: 272px;
height: 272px;
align-self: center;
}
.page-shell--default .dashboard-grid--portrait .calendar-card__clock-wrap :is(.analog-clock) {
transform: scale(1.2363636364);
transform-origin: top left;
}
.page-shell--default .dashboard-grid--portrait .weather-card {
/* 纵版天气卡压缩预报和指标区,给底部鸡汤腾空间。 */
grid-template-rows: auto minmax(0, 1.08fr) minmax(0, 0.72fr) minmax(0, 0.82fr);
gap: 0.54rem;
}
.page-shell--default .dashboard-grid--portrait .weather-card__hero {
gap: 0.62rem;
padding: 0.7rem 0.78rem;
}
.page-shell--default .dashboard-grid--portrait .weather-card__forecast {
gap: 0.28rem;
}
.page-shell--default .dashboard-grid--portrait .forecast-pill {
padding: 0.26rem 0.1rem;
}
.page-shell--default .dashboard-grid--portrait .forecast-pill__label {
font-size: calc(0.82rem * var(--theme-font-scale, 1) * var(--forecast-pill-scale, 1));
}
.page-shell--default .dashboard-grid--portrait .forecast-pill__temp {
font-size: calc(1rem * var(--theme-font-scale, 1) * var(--forecast-pill-scale, 1));
}
.page-shell--default .dashboard-grid--portrait .weather-card__fact-icon,
.page-shell--default .dashboard-grid--portrait .metric-pill__icon {
width: 2rem;
height: 2rem;
}
.page-shell--default .dashboard-grid--portrait .quote-card__icon {
width: 1.8rem;
height: 1.8rem;
}
.page-shell--default .weather-card__hero-main .glyph--large {
width: 5.6rem;
height: 5.6rem;
}
.dashboard-grid--landscape {
grid-template-columns: minmax(0, 1.24fr) minmax(21rem, 0.76fr);
grid-template-rows: minmax(0, 1fr) 216px;
@@ -196,6 +276,56 @@ img {
font-size: calc(6.2rem * var(--theme-font-scale, 1));
}
.page-shell--default .dashboard-grid--landscape .calendar-card {
/* default 横版顶部整体下压,避开 Kindle 右上角状态栏遮罩。 */
gap: 32px;
padding-top: 0;
}
.page-shell--default .dashboard-grid--landscape .calendar-card__hero {
grid-template-columns: minmax(0, 1fr) 320px;
align-items: end;
gap: 40px;
}
.page-shell--default .dashboard-grid--landscape .calendar-card__headline {
padding-left: 48px;
gap: 0.5rem;
}
.page-shell--default .dashboard-grid--landscape .calendar-card__day {
font-size: calc(9rem * var(--theme-font-scale, 1));
line-height: 0.78;
}
.page-shell--default .dashboard-grid--landscape .calendar-card__lunar-day,
.page-shell--default .dashboard-grid--landscape .calendar-card__weekday {
font-size: calc(2.75rem * var(--theme-font-scale, 1));
line-height: 1;
}
.page-shell--default .dashboard-grid--landscape .calendar-card__clock-wrap {
width: 320px;
height: 320px;
align-self: center;
}
.page-shell--default .dashboard-grid--landscape .calendar-card__clock-wrap :is(.analog-clock) {
transform: scale(1.4545454545);
transform-origin: top left;
}
.page-shell--default .dashboard-grid--landscape .weather-card__fact-icon,
.page-shell--default .dashboard-grid--landscape .metric-pill__icon {
width: 2rem;
height: 2rem;
}
.page-shell--default .dashboard-grid--landscape .quote-card__icon {
width: 1.8rem;
height: 1.8rem;
}
.dashboard-grid--landscape .weather-card {
grid-template-rows: auto minmax(0, 1fr) minmax(0, 0.86fr) minmax(0, 0.9fr);
gap: 0.58rem;