update at 2026-03-17 10:37:27

This commit is contained in:
douboer@gmail.com
2026-03-17 10:37:27 +08:00
parent e5becf63cf
commit 192eb1b8d1
44 changed files with 5208 additions and 403 deletions

306
calendar/config/themes.json Normal file
View File

@@ -0,0 +1,306 @@
{
"defaultThemeId": "default",
"defaultOrientation": "portrait",
"themes": [
{
"id": "default",
"label": "Default",
"preview": {
"pageBackground": "#efe8db",
"paper": "#ffffff",
"panelBackground": "#fffdf9",
"frameStroke": "#8b6b47",
"frameStrokeStrong": "#6f5235",
"frameMuted": "rgba(139, 107, 71, 0.35)",
"mutedInk": "#4c4c4c",
"badgeFill": "#faf6ef",
"bodyFont": "'Hiragino Sans GB', 'PingFang SC', 'Noto Sans SC', sans-serif",
"displayFont": "'Iowan Old Style', 'Baskerville', serif",
"titleFont": "'Hiragino Sans GB', 'PingFang SC', 'Noto Sans SC', sans-serif",
"cardRadius": "2rem",
"panelRadius": "1.25rem"
},
"variants": {
"portrait": {
"devicePlacement": "logo_bottom",
"viewport": {
"width": 1072,
"height": 1448
},
"backgroundPath": "themes/default/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/default/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": "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",
"preview": {
"pageBackground": "#ffffff",
"paper": "#ffffff",
"panelBackground": "#ffffff",
"frameStroke": "#1e1e1e",
"frameStrokeStrong": "#000000",
"frameMuted": "rgba(10, 10, 10, 0.32)",
"mutedInk": "#4a5565",
"badgeFill": "#ffffff",
"bodyFont": "'Inter', 'PingFang SC', 'Noto Sans SC', sans-serif",
"displayFont": "'Inter', 'PingFang SC', 'Noto Sans SC', sans-serif",
"titleFont": "'Inter', 'PingFang SC', 'Noto Sans SC', sans-serif",
"cardRadius": "32px",
"panelRadius": "32px"
},
"variants": {
"portrait": {
"devicePlacement": "logo_bottom",
"viewport": {
"width": 1072,
"height": 1448
},
"backgroundPath": "themes/simple/portrait/kindlebg.png",
"clock": {
"x": 544,
"y": 32,
"width": 480,
"height": 480,
"faceRadiusRatio": 0.5,
"faceStroke": 2,
"tickOuterInset": 21.735,
"majorTickOuterInset": 21.735,
"minorTickOuterInset": 21.735,
"majorTickLength": 47.348,
"minorTickLength": 21.735,
"majorTickThickness": 14.4,
"minorTickThickness": 6.521,
"hourLengthRatio": 0.62,
"hourBackLengthRatio": 0.28,
"minuteLengthRatio": 0.9090585774058577,
"minuteBackLengthRatio": 0.3,
"hourThickness": 14.4,
"minuteThickness": 9.6,
"centerRadius": 4.8
}
},
"landscape": {
"devicePlacement": "logo_right",
"viewport": {
"width": 1448,
"height": 1072
},
"backgroundPath": "themes/simple/landscape/kindlebg.png",
"clock": {
"x": 29,
"y": 562,
"width": 480,
"height": 480,
"faceRadiusRatio": 0.5,
"faceStroke": 2,
"tickOuterInset": 21.735,
"majorTickOuterInset": 21.735,
"minorTickOuterInset": 21.735,
"majorTickLength": 47.348,
"minorTickLength": 21.735,
"majorTickThickness": 14.4,
"minorTickThickness": 6.521,
"hourLengthRatio": 0.62,
"hourBackLengthRatio": 0.28,
"minuteLengthRatio": 0.9090585774058577,
"minuteBackLengthRatio": 0.3,
"hourThickness": 14.4,
"minuteThickness": 9.6,
"centerRadius": 4.8
}
}
}
}
]
}

View File

@@ -7,6 +7,7 @@
"dev": "vite",
"build": "vue-tsc --noEmit && vite build && node scripts/generate-dashboard-manifest.mjs",
"export:background": "sh scripts/export-kindle-background.sh",
"export:themes": "sh scripts/export-theme-backgrounds.sh",
"manifest": "node scripts/generate-dashboard-manifest.mjs",
"typecheck": "vue-tsc --noEmit",
"preview": "vite preview"

View File

@@ -5,7 +5,7 @@ ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)"
CALENDAR_DIR="$ROOT_DIR/calendar"
DIST_DIR="$CALENDAR_DIR/dist"
PORT=${PORT:-4173}
URL=${1:-"http://127.0.0.1:$PORT/?mode=background"}
URL=${1:-"http://127.0.0.1:$PORT/?mode=background&theme=default&orientation=portrait"}
OUT_PNG=${2:-"$DIST_DIR/kindlebg.png"}
OUT_REGION=${3:-"$DIST_DIR/clock-region.json"}

View File

@@ -27,16 +27,28 @@ enum ExportError: Error, CustomStringConvertible {
}
final class SnapshotExporter: NSObject, WKNavigationDelegate {
private enum ExportOrientation {
case portrait
case landscape
}
private struct ExportLayout {
let orientation: ExportOrientation
let viewportSize: CGSize
let outputSize: CGSize
}
private let url: URL
private let pngOutputURL: URL
private let regionOutputURL: URL
private let completion: (Result<Void, Error>) -> Void
// Kindle Voyage
private let targetSize = CGSize(width: 1072, height: 1448)
// Kindle framebuffer
// landscape 1448x1072 1072x1448
private let layout: ExportLayout
private lazy var window: NSWindow = {
let window = NSWindow(
contentRect: CGRect(origin: .zero, size: targetSize),
contentRect: CGRect(origin: .zero, size: layout.viewportSize),
styleMask: [.borderless],
backing: .buffered,
defer: false
@@ -48,7 +60,7 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
private lazy var webView: WKWebView = {
let config = WKWebViewConfiguration()
let view = WKWebView(frame: CGRect(origin: .zero, size: targetSize), configuration: config)
let view = WKWebView(frame: CGRect(origin: .zero, size: layout.viewportSize), configuration: config)
view.navigationDelegate = self
return view
}()
@@ -58,9 +70,29 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
self.pngOutputURL = pngOutputURL
self.regionOutputURL = regionOutputURL
self.completion = completion
self.layout = SnapshotExporter.resolveLayout(url: url)
super.init()
}
private static func resolveLayout(url: URL) -> ExportLayout {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let orientation = components?.queryItems?.first(where: { $0.name == "orientation" })?.value
if orientation == "landscape" {
return ExportLayout(
orientation: .landscape,
viewportSize: CGSize(width: 1448, height: 1072),
outputSize: CGSize(width: 1072, height: 1448)
)
}
return ExportLayout(
orientation: .portrait,
viewportSize: CGSize(width: 1072, height: 1448),
outputSize: CGSize(width: 1072, height: 1448)
)
}
func start() {
window.contentView = webView
window.orderBack(nil)
@@ -79,12 +111,22 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
(() => {
const node = document.querySelector('[data-clock-region="true"]');
if (!node) return null;
const dashboard = document.querySelector('.dashboard-frame');
const rect = node.getBoundingClientRect();
const frameRect = dashboard ? dashboard.getBoundingClientRect() : { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight };
return {
x: Math.round(rect.left),
y: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height)
clock: {
x: Math.round(rect.left),
y: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height)
},
frame: {
x: Math.round(frameRect.left),
y: Math.round(frameRect.top),
width: Math.round(frameRect.width),
height: Math.round(frameRect.height)
}
};
})();
"""
@@ -95,14 +137,18 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
return
}
guard let region = value as? [String: NSNumber] else {
guard
let result = value as? [String: Any],
let region = result["clock"] as? [String: NSNumber],
let frame = result["frame"] as? [String: NSNumber]
else {
self.finish(.failure(ExportError.clockRegionMissing))
return
}
let snapshotConfig = WKSnapshotConfiguration()
snapshotConfig.rect = CGRect(origin: .zero, size: self.targetSize)
snapshotConfig.snapshotWidth = NSNumber(value: Float(self.targetSize.width))
snapshotConfig.rect = CGRect(origin: .zero, size: self.layout.viewportSize)
snapshotConfig.snapshotWidth = NSNumber(value: Float(self.layout.viewportSize.width))
self.webView.takeSnapshot(with: snapshotConfig) { image, snapshotError in
if let snapshotError {
@@ -116,8 +162,8 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
}
do {
try self.savePNG(image: image, to: self.pngOutputURL)
try self.saveRegion(region: region)
try self.savePNG(image: image, to: self.pngOutputURL, frameOffset: frame)
try self.saveRegion(region: region, frameOffset: frame)
self.finish(.success(()))
} catch {
self.finish(.failure(error))
@@ -126,18 +172,18 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
}
}
private func savePNG(image: NSImage, to url: URL) throws {
let normalizedImage = NSImage(size: NSSize(width: targetSize.width, height: targetSize.height))
private func savePNG(image: NSImage, to url: URL, frameOffset: [String: NSNumber]) throws {
let normalizedImage = NSImage(size: NSSize(width: layout.viewportSize.width, height: layout.viewportSize.height))
normalizedImage.lockFocus()
image.draw(in: NSRect(origin: .zero, size: targetSize))
image.draw(in: NSRect(origin: .zero, size: layout.viewportSize))
normalizedImage.unlockFocus()
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 width = Int(layout.outputSize.width)
let height = Int(layout.outputSize.height)
let colorSpace = CGColorSpaceCreateDeviceGray()
// 8-bit PNG
@@ -156,7 +202,38 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
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))
let offsetX = CGFloat(frameOffset["x"]?.doubleValue ?? 0)
let offsetY = CGFloat(frameOffset["y"]?.doubleValue ?? 0)
// Kindle framebuffer landscape
switch layout.orientation {
case .portrait:
context.draw(
sourceCGImage,
in: CGRect(
x: -offsetX,
y: -offsetY,
width: layout.viewportSize.width,
height: layout.viewportSize.height
)
)
case .landscape:
context.saveGState()
// 90 framebuffer
// Kindle logo_right
context.translateBy(x: 0, y: layout.outputSize.height)
context.rotate(by: -.pi / 2)
context.draw(
sourceCGImage,
in: CGRect(
x: -offsetX,
y: -offsetY,
width: layout.viewportSize.width,
height: layout.viewportSize.height
)
)
context.restoreGState()
}
guard let grayscaleImage = context.makeImage() else {
throw ExportError.pngEncodingFailed(url.path)
@@ -180,18 +257,50 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
}
}
private func saveRegion(region: [String: NSNumber]) throws {
private func saveRegion(region: [String: NSNumber], frameOffset: [String: NSNumber]) throws {
let transformedRegion = transformRegion(region: region, frameOffset: frameOffset)
let jsonObject: [String: Int] = [
"x": region["x"]?.intValue ?? 0,
"y": region["y"]?.intValue ?? 0,
"width": region["width"]?.intValue ?? 0,
"height": region["height"]?.intValue ?? 0,
"x": transformedRegion["x"]?.intValue ?? 0,
"y": transformedRegion["y"]?.intValue ?? 0,
"width": transformedRegion["width"]?.intValue ?? 0,
"height": transformedRegion["height"]?.intValue ?? 0,
]
let data = try JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted, .sortedKeys])
try FileManager.default.createDirectory(at: regionOutputURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try data.write(to: regionOutputURL)
}
private func transformRegion(region: [String: NSNumber], frameOffset: [String: NSNumber]) -> [String: NSNumber] {
let normalizedX = CGFloat(region["x"]?.doubleValue ?? 0) - CGFloat(frameOffset["x"]?.doubleValue ?? 0)
let normalizedY = CGFloat(region["y"]?.doubleValue ?? 0) - CGFloat(frameOffset["y"]?.doubleValue ?? 0)
let normalizedRegion: [String: NSNumber] = [
"x": NSNumber(value: Int(round(normalizedX))),
"y": NSNumber(value: Int(round(normalizedY))),
"width": region["width"] ?? 0,
"height": region["height"] ?? 0,
]
guard layout.orientation == .landscape else {
return normalizedRegion
}
let x = CGFloat(normalizedRegion["x"]?.doubleValue ?? 0)
let y = CGFloat(normalizedRegion["y"]?.doubleValue ?? 0)
let width = CGFloat(normalizedRegion["width"]?.doubleValue ?? 0)
let height = CGFloat(normalizedRegion["height"]?.doubleValue ?? 0)
// logo 90 framebuffer
let transformedX = layout.viewportSize.height - (y + height)
let transformedY = x
return [
"x": NSNumber(value: Int(round(transformedX))),
"y": NSNumber(value: Int(round(transformedY))),
"width": NSNumber(value: Int(round(height))),
"height": NSNumber(value: Int(round(width))),
]
}
private func finish(_ result: Result<Void, Error>) {
completion(result)
NSApplication.shared.terminate(nil)

View File

@@ -0,0 +1,175 @@
#!/usr/bin/env sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)"
CALENDAR_DIR="$ROOT_DIR/calendar"
DIST_DIR="$CALENDAR_DIR/dist"
PORT=${PORT:-4173}
SWIFT_SCRIPT="$CALENDAR_DIR/scripts/export-kindle-background.swift"
THEMES_SOURCE="$CALENDAR_DIR/config/themes.json"
THEME_FILTER=""
ORIENTATION_FILTER=""
print_usage() {
cat <<'EOF'
用法:
sh scripts/export-theme-backgrounds.sh [选项]
选项:
--theme <theme-id> 只导出指定主题
--orientation <value> 只导出指定方向;必须和 --theme 一起使用
-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
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--theme)
shift
THEME_FILTER=${1:?"missing theme id"}
;;
--orientation)
shift
ORIENTATION_FILTER=${1:?"missing orientation"}
;;
-h|--help)
print_usage
exit 0
;;
*)
echo "未知参数: $1" >&2
echo >&2
print_usage >&2
exit 1
;;
esac
shift
done
if [ -n "$ORIENTATION_FILTER" ] && [ -z "$THEME_FILTER" ]; then
echo "--orientation 必须和 --theme 一起使用。" >&2
exit 1
fi
selection_output=$(
node --input-type=module -e "
import fs from 'node:fs';
const data = JSON.parse(fs.readFileSync(process.argv[1], 'utf8'));
const requestedTheme = process.argv[2];
const requestedOrientation = process.argv[3];
const themes = data.themes ?? [];
const themeMap = new Map(themes.map((theme) => [theme.id, theme]));
if (requestedTheme && !themeMap.has(requestedTheme)) {
console.error(\`未知主题: \${requestedTheme}\`);
process.exit(1);
}
if (requestedOrientation && !requestedTheme) {
console.error('--orientation 必须和 --theme 一起使用。');
process.exit(1);
}
const filteredThemes = requestedTheme ? themes.filter((theme) => theme.id === requestedTheme) : themes;
const items = [];
for (const theme of filteredThemes) {
const orientations = Object.keys(theme.variants ?? {});
if (requestedOrientation) {
if (!orientations.includes(requestedOrientation)) {
console.error(
\`主题 \${theme.id} 不支持方向 \${requestedOrientation},可用方向: \${orientations.join(', ')}\`,
);
process.exit(1);
}
}
const filteredOrientations = requestedOrientation ? [requestedOrientation] : orientations;
for (const orientation of filteredOrientations) {
const variant = theme.variants[orientation];
items.push([theme.id, orientation, variant.backgroundPath].join('\t'));
}
}
if (items.length === 0) {
console.error('没有可导出的主题背景。');
process.exit(1);
}
console.log(\`DEFAULT_THEME_ID=\${data.defaultThemeId}\`);
console.log(\`DEFAULT_ORIENTATION=\${data.defaultOrientation}\`);
for (const item of items) {
console.log(\`ITEM=\${item}\`);
}
" "$THEMES_SOURCE" "$THEME_FILTER" "$ORIENTATION_FILTER"
)
DEFAULT_THEME_ID=""
DEFAULT_ORIENTATION=""
EXPORT_ITEMS=""
while IFS= read -r line; do
case "$line" in
DEFAULT_THEME_ID=*)
DEFAULT_THEME_ID=${line#DEFAULT_THEME_ID=}
;;
DEFAULT_ORIENTATION=*)
DEFAULT_ORIENTATION=${line#DEFAULT_ORIENTATION=}
;;
ITEM=*)
if [ -n "$EXPORT_ITEMS" ]; then
EXPORT_ITEMS="${EXPORT_ITEMS}
${line#ITEM=}"
else
EXPORT_ITEMS=${line#ITEM=}
fi
;;
esac
done <<EOF
$selection_output
EOF
if [ -z "$DEFAULT_THEME_ID" ] || [ -z "$DEFAULT_ORIENTATION" ] || [ -z "$EXPORT_ITEMS" ]; then
echo "无法解析导出目标。" >&2
exit 1
fi
cd "$CALENDAR_DIR"
npm run build >/dev/null
python3 -m http.server "$PORT" -d "$DIST_DIR" >/tmp/kindle-calendar-http.log 2>&1 &
SERVER_PID=$!
trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT INT TERM
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"
url="http://127.0.0.1:$PORT/?mode=background&theme=$theme_id&orientation=$orientation"
/usr/bin/swift "$SWIFT_SCRIPT" "$url" "$out_png" "$out_region" >/dev/null
# 根目录的 kindlebg.png / clock-region.json 只给默认主题兜底使用。
# 定向导出其它主题时不覆盖它,避免把默认主题的运行时入口意外改掉。
if [ "$theme_id" = "$DEFAULT_THEME_ID" ] && [ "$orientation" = "$DEFAULT_ORIENTATION" ]; then
cp "$out_png" "$DIST_DIR/kindlebg.png"
cp "$out_region" "$DIST_DIR/clock-region.json"
fi
printf 'Exported %s %s -> %s\n' "$theme_id" "$orientation" "$out_png"
done
node "$CALENDAR_DIR/scripts/generate-dashboard-manifest.mjs" >/dev/null
if [ -f "$DIST_DIR/clock-region.json" ]; then
printf 'Default region saved to %s\n' "$DIST_DIR/clock-region.json"
else
printf 'Skipped default region update (default theme not exported this run)\n'
fi

View File

@@ -6,13 +6,28 @@ const currentDir = path.dirname(fileURLToPath(import.meta.url));
const distDir = path.resolve(currentDir, '../dist');
const manifestPath = path.join(distDir, 'dashboard-manifest.json');
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 dashboardBaseUrl = 'https://shell.biboer.cn:20001';
const defaultClockRegion = {
x: 313,
y: 0,
width: 220,
height: 220,
};
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 defaultClockRegion = defaultVariant
? {
x: defaultDeviceClock.x,
y: defaultDeviceClock.y,
width: defaultDeviceClock.width,
height: defaultDeviceClock.height,
}
: {
x: 313,
y: 0,
width: 220,
height: 220,
};
const clockRegion = fs.existsSync(clockRegionPath)
? {
@@ -22,10 +37,15 @@ const clockRegion = fs.existsSync(clockRegionPath)
: defaultClockRegion;
const manifest = {
theme: {
id: themesSource.defaultThemeId,
orientation: themesSource.defaultOrientation,
themesUrl: `${dashboardBaseUrl}/themes.json`,
},
background: {
path: 'kindlebg.png',
url: 'https://shell.biboer.cn:20001/kindlebg.png',
updatedAt: new Date().toISOString(),
url: `${dashboardBaseUrl}/kindlebg.png`,
updatedAt: generatedAt,
refreshIntervalMinutes: 120,
},
clockRegion,
@@ -45,6 +65,67 @@ const manifest = {
},
};
const themesIndex = {
updatedAt: generatedAt,
defaultThemeId: themesSource.defaultThemeId,
defaultOrientation: themesSource.defaultOrientation,
themes: themesSource.themes.map((theme) => ({
id: theme.id,
label: theme.label,
configUrl: `${dashboardBaseUrl}/themes/${theme.id}.json`,
orientations: Object.keys(theme.variants),
})),
};
function toDeviceClock(variant, orientation) {
if (orientation !== 'landscape') {
return {
...variant.clock,
rotationDegrees: 0,
};
}
return {
...variant.clock,
x: variant.viewport.height - (variant.clock.y + variant.clock.height),
y: variant.clock.x,
width: variant.clock.height,
height: variant.clock.width,
rotationDegrees: 90,
};
}
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,
},
clock: toDeviceClock(variant, orientation),
},
]),
),
};
}
fs.mkdirSync(distDir, { recursive: true });
fs.mkdirSync(themesDir, { recursive: true });
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
fs.writeFileSync(themesDistPath, `${JSON.stringify(themesIndex, null, 2)}\n`, 'utf8');
for (const theme of themesSource.themes) {
const themePath = path.join(themesDir, `${theme.id}.json`);
fs.writeFileSync(themePath, `${JSON.stringify(buildThemeConfig(theme), null, 2)}\n`, 'utf8');
}
console.log(`Wrote ${manifestPath}`);
console.log(`Wrote ${themesDistPath}`);

View File

@@ -4,18 +4,30 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import AnalogClock from '@/components/AnalogClock.vue';
import CalendarCard from '@/components/CalendarCard.vue';
import QuoteCard from '@/components/QuoteCard.vue';
import SimpleDashboard from '@/components/SimpleDashboard.vue';
import WeatherCard from '@/components/WeatherCard.vue';
import { buildCalendarModel } from '@/lib/calendar';
import { resolveDashboardMode } from '@/lib/dashboard-mode';
import {
DASHBOARD_THEMES,
buildDashboardSearch,
getDashboardTheme,
getDashboardVariant,
resolveDashboardOrientation,
resolveDashboardThemeId,
type DashboardOrientation,
} from '@/lib/dashboard-theme';
import { getQuoteForDate } from '@/lib/quotes';
import { fetchWeather, resolveLocation, type LocationCoordinates, type WeatherSnapshot } from '@/lib/weather';
const now = ref(new Date());
const mode = ref(resolveDashboardMode(window.location.search));
const themeId = ref(resolveDashboardThemeId(window.location.search));
const orientation = ref<DashboardOrientation>(resolveDashboardOrientation(window.location.search));
const location = ref<LocationCoordinates>({
latitude: 31.2304,
longitude: 121.4737,
label: '上海',
latitude: 30.274084,
longitude: 120.15507,
label: '杭州',
});
const weather = ref<WeatherSnapshot | null>(null);
const weatherStatus = ref<'idle' | 'loading' | 'ready' | 'error'>('idle');
@@ -26,6 +38,38 @@ let weatherTimer = 0;
const calendarModel = computed(() => buildCalendarModel(now.value));
const quoteEntry = computed(() => getQuoteForDate(now.value));
const isClockFaceMode = computed(() => mode.value === 'clock-face');
const isSimpleTheme = computed(() => themeId.value === 'simple');
const showPreviewControls = computed(() => mode.value === 'full');
const selectedTheme = computed(() => getDashboardTheme(themeId.value));
const selectedVariant = computed(() => getDashboardVariant(themeId.value, orientation.value));
const dashboardStyle = computed(() => ({
'--dashboard-width': `${selectedVariant.value.viewport.width}px`,
'--dashboard-height': `${selectedVariant.value.viewport.height}px`,
'--dashboard-aspect': `${selectedVariant.value.viewport.width} / ${selectedVariant.value.viewport.height}`,
'--page-background': selectedTheme.value.preview.pageBackground,
'--paper': selectedTheme.value.preview.paper,
'--panel-background': selectedTheme.value.preview.panelBackground,
'--frame-stroke': selectedTheme.value.preview.frameStroke,
'--frame-stroke-strong': selectedTheme.value.preview.frameStrokeStrong,
'--frame-muted': selectedTheme.value.preview.frameMuted,
'--muted-ink': selectedTheme.value.preview.mutedInk,
'--badge-fill': selectedTheme.value.preview.badgeFill,
'--body-font': selectedTheme.value.preview.bodyFont,
'--display-font': selectedTheme.value.preview.displayFont,
'--title-font': selectedTheme.value.preview.titleFont,
'--card-radius': selectedTheme.value.preview.cardRadius,
'--panel-radius': selectedTheme.value.preview.panelRadius,
// default 主题整体字号翻倍,但鸡汤正文保持原尺寸。
'--theme-font-scale': themeId.value === 'default' ? '2' : '1',
'--quote-content-font-scale': '1',
// 四天天气小卡在 default 主题下单独收紧,避免放大后溢出。
'--forecast-pill-scale': themeId.value === 'default' ? '0.78' : '1',
}));
const orientationOptions: Array<{ value: DashboardOrientation; label: string }> = [
{ value: 'portrait', label: '纵向Logo 下)' },
{ value: 'landscape', label: '横向Logo 右)' },
];
async function refreshWeather() {
weatherStatus.value = 'loading';
@@ -41,6 +85,18 @@ async function refreshWeather() {
function syncMode() {
mode.value = resolveDashboardMode(window.location.search);
themeId.value = resolveDashboardThemeId(window.location.search);
orientation.value = resolveDashboardOrientation(window.location.search);
}
function updateSearch() {
const nextSearch = buildDashboardSearch({
mode: mode.value,
themeId: themeId.value,
orientation: orientation.value,
});
window.history.replaceState({}, '', nextSearch);
}
onMounted(async () => {
@@ -71,20 +127,55 @@ onBeforeUnmount(() => {
</script>
<template>
<main :class="['page-shell', `page-shell--${mode}`]">
<section v-if="isClockFaceMode" class="clock-face-stage">
<AnalogClock :date="now" mode="clock-face" :size="220" />
</section>
<main
:class="['page-shell', `page-shell--${mode}`, `page-shell--${orientation}`, `page-shell--${themeId}`]"
:style="dashboardStyle"
>
<div class="page-stack">
<header v-if="showPreviewControls" class="preview-toolbar">
<label class="preview-toolbar__field">
<span class="preview-toolbar__label">主题</span>
<select v-model="themeId" class="preview-toolbar__select" @change="updateSearch">
<option v-for="theme in DASHBOARD_THEMES" :key="theme.id" :value="theme.id">
{{ theme.label }}
</option>
</select>
</label>
<label class="preview-toolbar__field">
<span class="preview-toolbar__label">方向</span>
<select v-model="orientation" class="preview-toolbar__select" @change="updateSearch">
<option v-for="option in orientationOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
</header>
<div v-else class="dashboard-frame">
<div class="dashboard-grid">
<CalendarCard :model="calendarModel" :date="now" :mode="mode" />
<WeatherCard
:weather="weather"
:status="weatherStatus"
<section v-if="isClockFaceMode" class="clock-face-stage">
<AnalogClock :date="now" mode="clock-face" :size="220" />
</section>
<div v-else :class="['dashboard-frame', `dashboard-frame--${orientation}`]">
<SimpleDashboard
v-if="isSimpleTheme"
:model="calendarModel"
:date="now"
:mode="mode"
:orientation="orientation"
:location-label="location.label"
:weather="weather"
:quote="quoteEntry.text"
/>
<QuoteCard :quote="quoteEntry.text" />
<div v-else :class="['dashboard-grid', `dashboard-grid--${orientation}`]">
<CalendarCard class="dashboard-grid__calendar" :model="calendarModel" :date="now" :mode="mode" />
<WeatherCard
class="dashboard-grid__weather"
:weather="weather"
:status="weatherStatus"
:location-label="location.label"
/>
<QuoteCard class="dashboard-grid__quote" :quote="quoteEntry.text" />
</div>
</div>
</div>
</main>

View File

@@ -43,7 +43,6 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
<div class="calendar-card__panel-header">
<div>
<p class="calendar-card__panel-title">{{ model.gregorianLabel }}</p>
<p class="calendar-card__panel-subtitle">{{ model.lunarYearLabel }}</p>
</div>
<div v-if="model.summaryBadges.length" class="calendar-card__badges">
<span
@@ -98,13 +97,16 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
grid-template-rows: auto 1fr;
height: 100%;
gap: 1rem;
padding: 1.28rem 1.28rem 1.16rem;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.calendar-card__hero {
display: flex;
display: grid;
grid-template-columns: minmax(0, 1fr) 220px;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
@@ -124,27 +126,24 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
}
.calendar-card__day {
font-family:
'Iowan Old Style',
'Baskerville',
serif;
font-size: 6.9rem;
font-family: var(--display-font);
font-size: calc(6.9rem * var(--theme-font-scale, 1));
line-height: 0.88;
letter-spacing: -0.08em;
color: #000000;
color: var(--ink);
}
.calendar-card__lunar-day,
.calendar-card__weekday {
margin: 0;
font-size: 1.88rem;
font-size: calc(1.88rem * var(--theme-font-scale, 1));
line-height: 1.02;
color: #000000;
color: var(--ink);
white-space: nowrap;
}
.calendar-card__weekday {
font-size: 1.88rem;
font-size: calc(1.88rem * var(--theme-font-scale, 1));
}
.calendar-card__panel {
@@ -153,9 +152,9 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
gap: 0.55rem;
min-height: 0;
padding: 0.88rem 0.94rem 0.94rem;
border-radius: 1.25rem;
border-radius: var(--panel-radius);
border: 2px solid var(--frame-stroke);
background: #ffffff;
background: var(--panel-background);
}
.calendar-card__panel-header {
@@ -165,20 +164,13 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
gap: 1rem;
}
.calendar-card__panel-title,
.calendar-card__panel-subtitle {
.calendar-card__panel-title {
margin: 0;
}
.calendar-card__panel-title {
font-size: 0.9rem;
color: #4c4c4c;
}
.calendar-card__panel-subtitle {
margin-top: 0.2rem;
font-size: 0.84rem;
color: #000000;
font-size: calc(0.9rem * var(--theme-font-scale, 1));
color: var(--muted-ink);
}
.calendar-card__badges {
@@ -191,11 +183,11 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
.calendar-card__badge {
padding: 0.14rem 0.46rem;
border-radius: 999px;
font-size: 0.72rem;
font-size: calc(0.72rem * var(--theme-font-scale, 1));
line-height: 1.2;
color: #000000;
color: var(--ink);
border: 1.5px solid var(--frame-stroke);
background: #ffffff;
background: var(--badge-fill);
}
.calendar-card__grid {
@@ -212,8 +204,8 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
display: grid;
place-items: center;
padding-bottom: 0.14rem;
font-size: 0.82rem;
color: #000000;
font-size: calc(0.82rem * var(--theme-font-scale, 1));
color: var(--ink);
}
.calendar-card__cell {
@@ -223,7 +215,7 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
padding: 0.16rem 0 0.18rem;
border-radius: 0.9rem;
border: 1.5px solid transparent;
color: #000000;
color: var(--ink);
}
.calendar-card__cell-copy {
@@ -238,21 +230,21 @@ function subLabelTone(cell: CalendarModel['cells'][number]) {
}
.calendar-card__solar {
font-size: 0.98rem;
font-size: calc(0.98rem * var(--theme-font-scale, 1));
line-height: 1.05;
}
.calendar-card__sub {
max-width: 100%;
overflow: hidden;
font-size: 0.84rem;
font-size: calc(0.84rem * var(--theme-font-scale, 1));
line-height: 1;
color: #000000;
color: var(--ink);
text-overflow: ellipsis;
white-space: nowrap;
}
.calendar-card__cell--muted {
border-color: rgba(139, 107, 71, 0.35);
border-color: var(--frame-muted);
}
</style>

View File

@@ -11,18 +11,18 @@ const quoteFontSize = computed(() => {
const length = props.quote.length;
if (length > 120) {
return '1.05rem';
return 'calc(1.05rem * var(--quote-content-font-scale, 1))';
}
if (length > 80) {
return '1.22rem';
return 'calc(1.22rem * var(--quote-content-font-scale, 1))';
}
if (length > 48) {
return '1.4rem';
return 'calc(1.4rem * var(--quote-content-font-scale, 1))';
}
return '1.6rem';
return 'calc(1.6rem * var(--quote-content-font-scale, 1))';
});
</script>
@@ -42,7 +42,7 @@ const quoteFontSize = computed(() => {
display: grid;
gap: 0.34rem;
padding: 0.72rem 1.02rem;
background: #ffffff;
background: var(--panel-background);
overflow: hidden;
}
@@ -50,7 +50,7 @@ const quoteFontSize = computed(() => {
display: inline-flex;
align-items: center;
gap: 0.45rem;
color: #000000;
color: var(--ink);
}
.quote-card__icon {
@@ -60,13 +60,14 @@ const quoteFontSize = computed(() => {
}
.quote-card__title {
font-size: 0.8rem;
font-family: var(--title-font);
font-size: calc(0.8rem * var(--theme-font-scale, 1));
font-weight: 600;
}
.quote-card__content {
margin: 0;
line-height: 1.34;
color: #000000;
color: var(--ink);
}
</style>

View File

@@ -0,0 +1,164 @@
<script setup lang="ts">
import { computed } from 'vue';
import { buildClockState } from '@/lib/clock';
import type { DashboardMode } from '@/lib/dashboard-mode';
const SIMPLE_CLOCK_SIZE = 480;
// 5 分钟刻度的外沿需要和普通刻度落在同一圈上,
// 所以这里按统一 outer inset 重新计算大刻度中心距离。
const SIMPLE_HOUR_TICK_DISTANCE = 194.59034156799316;
const SIMPLE_MINUTE_TICK_DISTANCE = 207.39662265777588;
const SIMPLE_HOUR_HAND_FRONT = 148.8;
const SIMPLE_HOUR_HAND_BACK = 67.2;
// 分针前端要求和分钟刻度外端严格对齐。
const SIMPLE_MINUTE_HAND_FRONT = 218.26450538635254;
const SIMPLE_MINUTE_HAND_BACK = 72;
const SIMPLE_MINUTE_HAND_THICKNESS = 9.6;
const props = withDefaults(
defineProps<{
date: Date;
mode: DashboardMode;
size?: number;
}>(),
{
size: SIMPLE_CLOCK_SIZE,
},
);
const clockState = computed(() => buildClockState(props.date));
const scale = computed(() => props.size / SIMPLE_CLOCK_SIZE);
// simple 主题的表盘不是图片,而是按 Figma 参数直接绘制。
const hourTicks = Array.from({ length: 12 }, (_, index) => index * 30);
const minuteTicks = Array.from({ length: 60 }, (_, index) => index).filter((index) => index % 5 !== 0);
const stageStyle = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
}));
function buildHourTickStyle(angle: number) {
return {
width: `${14.4 * scale.value}px`,
height: `${47.34832763671875 * scale.value}px`,
transform: `translate(-50%, -50%) rotate(${angle}deg) translateY(-${SIMPLE_HOUR_TICK_DISTANCE * scale.value}px)`,
};
}
function buildMinuteTickStyle(index: number) {
return {
width: `${6.520815372467041 * scale.value}px`,
height: `${21.73549461364746 * scale.value}px`,
transform: `translate(-50%, -50%) rotate(${index * 6}deg) translateY(-${SIMPLE_MINUTE_TICK_DISTANCE * scale.value}px)`,
};
}
const hourHandStyle = computed(() => ({
width: `${14.4 * scale.value}px`,
height: `${(SIMPLE_HOUR_HAND_FRONT + SIMPLE_HOUR_HAND_BACK) * scale.value}px`,
transformOrigin: `50% ${SIMPLE_HOUR_HAND_BACK * scale.value}px`,
transform: `translate(-50%, -${SIMPLE_HOUR_HAND_BACK * scale.value}px) rotate(${clockState.value.hourAngle}deg)`,
}));
const minuteHandStyle = computed(() => ({
width: `${SIMPLE_MINUTE_HAND_THICKNESS * scale.value}px`,
height: `${(SIMPLE_MINUTE_HAND_FRONT + SIMPLE_MINUTE_HAND_BACK) * scale.value}px`,
transformOrigin: `50% ${SIMPLE_MINUTE_HAND_BACK * scale.value}px`,
transform: `translate(-50%, -${SIMPLE_MINUTE_HAND_BACK * scale.value}px) rotate(${clockState.value.minuteAngle}deg)`,
}));
</script>
<template>
<div class="simple-analog-clock" :style="stageStyle" data-clock-region="true">
<template v-if="mode !== 'background'">
<div class="simple-analog-clock__face-shadow" />
<div class="simple-analog-clock__face" />
<div
v-for="angle in hourTicks"
:key="`hour-${angle}`"
class="simple-analog-clock__tick simple-analog-clock__tick--hour"
:style="buildHourTickStyle(angle)"
/>
<div
v-for="index in minuteTicks"
:key="`minute-${index}`"
class="simple-analog-clock__tick simple-analog-clock__tick--minute"
:style="buildMinuteTickStyle(index)"
/>
<div class="simple-analog-clock__hand simple-analog-clock__hand--minute" :style="minuteHandStyle" />
<div class="simple-analog-clock__hand simple-analog-clock__hand--hour" :style="hourHandStyle" />
<div class="simple-analog-clock__center simple-analog-clock__center--bottom" />
<div class="simple-analog-clock__center simple-analog-clock__center--top" />
</template>
</div>
</template>
<style scoped>
.simple-analog-clock {
position: relative;
flex: 0 0 auto;
}
.simple-analog-clock__face-shadow,
.simple-analog-clock__face {
position: absolute;
border-radius: 50%;
background: #ffffff;
}
.simple-analog-clock__face-shadow {
inset: -2.4px;
box-shadow: 0 1.8px 5.4px rgba(0, 0, 0, 0.3);
}
.simple-analog-clock__face {
inset: 0;
border: 1.6px solid #1e1e1e;
}
.simple-analog-clock__tick {
position: absolute;
top: 50%;
left: 50%;
background: #1e1e1e;
}
.simple-analog-clock__hand {
position: absolute;
top: 50%;
left: 50%;
background: #000000;
}
.simple-analog-clock__hand--minute {
box-shadow: 0 3.6px 10.8px rgba(0, 0, 0, 0.4);
}
.simple-analog-clock__hand--hour {
box-shadow: 2.4px 2.4px 10.8px rgba(0, 0, 0, 0.4);
}
.simple-analog-clock__center {
position: absolute;
top: 50%;
left: 50%;
border-radius: 50%;
transform: translate(-50%, -50%);
background: #000000;
}
.simple-analog-clock__center--bottom {
width: 4.8px;
height: 4.8px;
}
.simple-analog-clock__center--top {
width: 9.6px;
height: 9.6px;
}
</style>

View File

@@ -0,0 +1,656 @@
<script setup lang="ts">
import { computed } from 'vue';
import SimpleAnalogClock from '@/components/SimpleAnalogClock.vue';
import WeatherGlyph from '@/components/WeatherGlyph.vue';
import { weatherKindFromCode } from '@/lib/icon-assets';
import type { CalendarModel } from '@/lib/calendar';
import type { DashboardMode } from '@/lib/dashboard-mode';
import type { DashboardOrientation } from '@/lib/dashboard-theme';
import type { ForecastDay, WeatherSnapshot } from '@/lib/weather';
import simpleBookIcon from '../../../assets/simple/book.svg';
import simpleHumidityIcon from '../../../assets/simple/humidity.svg';
import simpleLocationIcon from '../../../assets/simple/location.svg';
import simplePm25Icon from '../../../assets/simple/pm25.svg';
const props = defineProps<{
model: CalendarModel;
date: Date;
mode: DashboardMode;
orientation: DashboardOrientation;
locationLabel: string;
weather: WeatherSnapshot | null;
quote: string;
}>();
interface SimpleForecastItem {
label: string;
weatherCode: number;
high: number;
low: number;
}
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;
});
// simple 主题需要固定 5 个预报块;天气未返回时也要保住版面。
const forecastItems = computed<SimpleForecastItem[]>(() => {
if (props.weather) {
return props.weather.forecast.slice(0, 5);
}
return Array.from({ length: 5 }, (_, index) => {
const current = new Date(props.date);
current.setDate(current.getDate() + index);
return {
label:
index === 0
? '今天'
: index === 1
? '明天'
: new Intl.DateTimeFormat('zh-CN', { weekday: 'short' }).format(current),
weatherCode: 2,
high: 24,
low: 18,
};
});
});
const currentWeatherKind = computed(() => weatherKindFromCode(props.weather?.weatherCode ?? 2));
const currentCondition = computed(() => props.weather?.condition ?? '天气');
const currentAqi = computed(() => (props.weather?.aqi === null || props.weather?.aqi === undefined ? '--' : String(props.weather.aqi)));
const currentHumidity = computed(() => (props.weather ? `${props.weather.humidity}%` : '--'));
function forecastKind(day: ForecastDay | SimpleForecastItem) {
return weatherKindFromCode(day.weatherCode);
}
function forecastTemperature(day: ForecastDay | SimpleForecastItem) {
return `${day.low}°-${day.high}°`;
}
function isCompactCalendarLabel(label: string) {
return label.length > 4;
}
</script>
<template>
<section :class="['simple-dashboard', `simple-dashboard--${orientation}`]">
<template v-if="orientation === 'portrait'">
<section class="simple-dashboard__top">
<div class="simple-dashboard__summary simple-dashboard__summary--portrait">
<div class="simple-dashboard__headline">
<div class="simple-dashboard__day">{{ model.largeDay }}</div>
<div class="simple-dashboard__weather-hero">
<WeatherGlyph :kind="currentWeatherKind" large class="simple-dashboard__weather-hero-icon" />
<p class="simple-dashboard__weather-hero-label">{{ currentCondition }}</p>
</div>
</div>
<div class="simple-dashboard__weekday-line">
<p class="simple-dashboard__weekday simple-dashboard__weekday--portrait">{{ model.weekdayLabel }}</p>
<p class="simple-dashboard__location simple-dashboard__location--portrait">{{ locationLabel }}</p>
</div>
<section class="simple-forecast simple-forecast--portrait">
<article v-for="day in forecastItems" :key="day.label" class="simple-forecast__item">
<p class="simple-forecast__label">{{ day.label }}</p>
<WeatherGlyph :kind="forecastKind(day)" large class="simple-forecast__icon" />
<p class="simple-forecast__temp">{{ forecastTemperature(day) }}</p>
</article>
</section>
</div>
<SimpleAnalogClock :date="date" :mode="mode" :size="480" />
</section>
<section class="simple-calendar simple-calendar--portrait">
<header class="simple-calendar__weekdays">
<span v-for="label in model.weekLabels" :key="label" class="simple-calendar__weekday-label">{{ label }}</span>
</header>
<div class="simple-calendar__grid" :style="{ gridTemplateRows: `repeat(${weeks.length}, minmax(0, 1fr))` }">
<article
v-for="cell in model.cells"
:key="cell.date.toISOString()"
:class="['simple-calendar__cell', { 'simple-calendar__cell--muted': !cell.currentMonth }]"
>
<span class="simple-calendar__solar">{{ cell.day }}</span>
<span
:class="[
'simple-calendar__sub',
{ 'simple-calendar__sub--compact': isCompactCalendarLabel(cell.subLabel) },
]"
>
{{ cell.subLabel }}
</span>
</article>
</div>
</section>
<section class="simple-quote simple-quote--portrait">
<div class="simple-quote__header">
<img class="simple-quote__icon" :src="simpleBookIcon" alt="" aria-hidden="true" />
<span class="simple-quote__title">每日鸡汤</span>
</div>
<p class="simple-quote__body">{{ quote }}</p>
</section>
</template>
<template v-else>
<div class="simple-dashboard__column simple-dashboard__column--left">
<section class="simple-dashboard__summary simple-dashboard__summary--landscape">
<div class="simple-dashboard__landscape-headline">
<div class="simple-dashboard__day">{{ model.largeDay }}</div>
<p class="simple-dashboard__weekday simple-dashboard__weekday--landscape">{{ model.weekdayLabel }}</p>
</div>
<div class="simple-dashboard__location-row">
<img class="simple-dashboard__location-icon" :src="simpleLocationIcon" alt="" aria-hidden="true" />
<p class="simple-dashboard__location simple-dashboard__location--landscape">{{ locationLabel }}</p>
</div>
<div class="simple-dashboard__metrics">
<div class="simple-dashboard__metric">
<WeatherGlyph :kind="currentWeatherKind" large class="simple-dashboard__metric-icon simple-dashboard__metric-icon--weather" />
<p class="simple-dashboard__metric-value">{{ currentCondition }}</p>
</div>
<div class="simple-dashboard__metric">
<img class="simple-dashboard__metric-icon simple-dashboard__metric-icon--pm25" :src="simplePm25Icon" alt="" aria-hidden="true" />
<p class="simple-dashboard__metric-value">{{ currentAqi }}</p>
</div>
<div class="simple-dashboard__metric">
<img class="simple-dashboard__metric-icon simple-dashboard__metric-icon--humidity" :src="simpleHumidityIcon" alt="" aria-hidden="true" />
<p class="simple-dashboard__metric-value">{{ currentHumidity }}</p>
</div>
</div>
</section>
<SimpleAnalogClock :date="date" :mode="mode" :size="480" />
</div>
<div class="simple-dashboard__column simple-dashboard__column--right">
<section class="simple-forecast simple-forecast--landscape">
<article v-for="day in forecastItems" :key="day.label" class="simple-forecast__item">
<p class="simple-forecast__label">{{ day.label }}</p>
<WeatherGlyph :kind="forecastKind(day)" large class="simple-forecast__icon" />
<p class="simple-forecast__temp">{{ forecastTemperature(day) }}</p>
</article>
</section>
<section class="simple-calendar simple-calendar--landscape">
<header class="simple-calendar__weekdays">
<span v-for="label in model.weekLabels" :key="label" class="simple-calendar__weekday-label">{{ label }}</span>
</header>
<div class="simple-calendar__grid" :style="{ gridTemplateRows: `repeat(${weeks.length}, minmax(0, 1fr))` }">
<article
v-for="cell in model.cells"
:key="cell.date.toISOString()"
:class="['simple-calendar__cell', { 'simple-calendar__cell--muted': !cell.currentMonth }]"
>
<span class="simple-calendar__solar">{{ cell.day }}</span>
<span
:class="[
'simple-calendar__sub',
{ 'simple-calendar__sub--compact': isCompactCalendarLabel(cell.subLabel) },
]"
>
{{ cell.subLabel }}
</span>
</article>
</div>
</section>
<section class="simple-quote simple-quote--landscape">
<div class="simple-quote__header">
<img class="simple-quote__icon" :src="simpleBookIcon" alt="" aria-hidden="true" />
<span class="simple-quote__title">每日鸡汤</span>
</div>
<p class="simple-quote__body simple-quote__body--landscape">{{ quote }}</p>
</section>
</div>
</template>
</section>
</template>
<style scoped>
.simple-dashboard {
width: 100%;
height: 100%;
color: #0a0a0a;
background: #ffffff;
font-family: var(--body-font, 'Inter', 'PingFang SC', 'Noto Sans SC', sans-serif);
}
.simple-dashboard--portrait {
display: grid;
grid-template-rows: 480px minmax(0, 1fr) auto;
gap: 32px;
padding: 32px;
}
.simple-dashboard--landscape {
display: grid;
grid-template-columns: 480px 876px;
gap: 34px;
padding: 30px 29px;
}
.simple-dashboard__top {
display: grid;
grid-template-columns: 480px 480px;
width: 1008px;
height: 480px;
justify-content: center;
gap: 16px;
padding: 0 16px;
justify-self: start;
align-self: start;
}
.simple-dashboard__column {
min-width: 0;
}
.simple-dashboard__column--left {
display: grid;
grid-template-rows: 464px 480px;
gap: 68px;
}
.simple-dashboard__column--right {
display: grid;
grid-template-rows: 161px minmax(0, 1fr) auto;
gap: 24px;
}
.simple-dashboard__summary {
min-width: 0;
}
.simple-dashboard__summary--portrait {
display: grid;
grid-template-rows: 156px 56px 111.668px;
align-content: start;
gap: 64px;
}
.simple-dashboard__summary--landscape {
display: grid;
grid-template-rows: 156px 57px 99px;
align-content: start;
gap: 64px;
padding-bottom: 24px;
}
.simple-dashboard__headline {
display: flex;
align-items: center;
gap: 61px;
}
.simple-dashboard__landscape-headline {
display: flex;
align-items: flex-end;
gap: 24px;
}
.simple-dashboard__day {
width: 236px;
font-size: 220px;
font-weight: 400;
line-height: 156px;
letter-spacing: -0.1504px;
color: #000000;
}
.simple-dashboard__weather-hero {
display: grid;
grid-template-rows: 56px 57px;
justify-items: center;
row-gap: 26px;
width: 139px;
height: 139px;
}
.simple-dashboard__weather-hero-icon {
width: 77.204px;
height: 56px;
}
.simple-dashboard__weather-hero-label {
width: 139px;
margin: 0;
font-size: 56px;
font-weight: 700;
line-height: 57px;
text-align: center;
white-space: nowrap;
}
.simple-dashboard__weekday-line {
display: flex;
align-items: flex-end;
gap: 24px;
width: 480px;
min-width: 0;
}
.simple-dashboard__weekday {
margin: 0;
font-weight: 700;
white-space: nowrap;
}
.simple-dashboard__weekday--portrait {
flex: 0 0 auto;
font-size: 80px;
line-height: 56px;
}
.simple-dashboard__weekday--landscape {
width: 191px;
font-size: 56px;
line-height: 57px;
}
.simple-dashboard__location-row {
display: flex;
align-items: center;
gap: 24px;
width: 480px;
min-width: 0;
}
.simple-dashboard__location-icon {
width: 32px;
height: 32px;
flex: 0 0 auto;
}
.simple-dashboard__location {
margin: 0;
min-width: 0;
overflow: hidden;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.simple-dashboard__location--portrait {
flex: 1 1 auto;
font-size: 56px;
line-height: 56px;
}
.simple-dashboard__location--landscape {
width: 424px;
font-size: 56px;
line-height: 57px;
}
.simple-dashboard__metrics {
display: flex;
align-items: flex-start;
gap: 24px;
width: 480px;
height: 99px;
overflow: visible;
}
.simple-dashboard__metric {
display: grid;
grid-template-rows: 58px 60px;
justify-items: center;
align-content: center;
row-gap: 22px;
width: 139px;
height: 140px;
transform: translateY(-20.5px);
}
.simple-dashboard__metric-icon {
object-fit: contain;
}
.simple-dashboard__metric-icon--weather {
width: 77.204px;
height: 57.226px;
}
.simple-dashboard__metric-icon--pm25 {
width: 60.345px;
height: 58.947px;
}
.simple-dashboard__metric-icon--humidity {
width: 54.504px;
height: 56px;
}
.simple-dashboard__metric-value {
width: 139px;
margin: 0;
font-size: 56px;
font-weight: 700;
line-height: 60px;
text-align: center;
white-space: nowrap;
}
.simple-forecast {
border: 1px solid #1e1e1e;
border-radius: 32px;
overflow: hidden;
}
.simple-forecast--portrait {
display: flex;
align-items: center;
gap: 22.201px;
padding: 9.952px 26.794px;
}
.simple-forecast--landscape {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
align-items: center;
padding: 16px 0;
}
.simple-forecast__item {
display: grid;
justify-items: center;
align-content: center;
text-align: center;
}
.simple-forecast--portrait .simple-forecast__item {
flex: 0 0 auto;
width: 64.612px;
row-gap: 9.952px;
}
.simple-forecast--portrait .simple-forecast__item:first-child {
width: 79.158px;
}
.simple-forecast--landscape .simple-forecast__item {
height: 129px;
row-gap: 16px;
}
.simple-forecast__label,
.simple-forecast__temp {
margin: 0;
white-space: nowrap;
}
.simple-forecast--portrait .simple-forecast__label {
font-size: 17.148px;
font-weight: 400;
line-height: 24.498px;
color: #4a5565;
}
.simple-forecast--landscape .simple-forecast__label {
font-size: 36px;
font-weight: 400;
line-height: 24.498px;
color: #4a5565;
}
.simple-forecast__icon {
object-fit: contain;
}
.simple-forecast--portrait .simple-forecast__icon {
width: 41.184px;
height: 22.864px;
}
.simple-forecast--landscape .simple-forecast__icon {
width: 64.844px;
height: 36px;
}
.simple-forecast--portrait .simple-forecast__temp {
font-size: 17.148px;
font-weight: 500;
line-height: 24.498px;
color: #0a0a0a;
}
.simple-forecast--landscape .simple-forecast__temp {
font-size: 24px;
font-weight: 500;
line-height: 24.498px;
color: #0a0a0a;
}
.simple-calendar {
display: grid;
grid-template-rows: 36px minmax(0, 1fr);
height: 100%;
gap: 8px;
padding: 8px;
border: 1px solid #000000;
border-radius: 32px;
overflow: hidden;
}
.simple-calendar__weekdays {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
align-items: center;
padding: 8px;
}
.simple-calendar__weekday-label {
font-size: 32px;
font-weight: 500;
line-height: 20px;
text-align: center;
color: #000000;
}
.simple-calendar__grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 8px;
min-height: 0;
padding: 8px;
}
.simple-calendar__cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 0;
color: #000000;
}
.simple-calendar__cell--muted {
opacity: 0.32;
}
.simple-calendar__solar {
font-size: 32px;
font-weight: 500;
line-height: 23.077px;
}
.simple-calendar__sub {
font-size: 24px;
font-weight: 500;
line-height: 23.077px;
text-align: center;
max-width: 100%;
overflow-wrap: anywhere;
white-space: nowrap;
}
.simple-calendar__sub--compact {
font-size: 12px;
line-height: 13px;
white-space: normal;
}
.simple-quote {
display: grid;
grid-template-rows: 44.8px minmax(0, 1fr);
align-self: start;
gap: 19.2px;
padding: 25.6px;
border: 1.6px solid #000000;
border-radius: 51.2px;
overflow: hidden;
}
.simple-quote__header {
display: flex;
align-items: center;
gap: 12.8px;
height: 44.8px;
}
.simple-quote__icon {
width: 38.4px;
height: 38.4px;
}
.simple-quote__title {
font-size: 32px;
font-weight: 500;
line-height: 44.8px;
letter-spacing: -0.7188px;
white-space: nowrap;
}
.simple-quote__body {
margin: 0;
font-size: 21.333px;
font-style: italic;
font-weight: 400;
line-height: 36.533px;
letter-spacing: 0.1125px;
}
.simple-quote__body--landscape {
font-size: 16px;
line-height: 27.2px;
}
</style>

View File

@@ -8,6 +8,7 @@ import {
SUNSET_ICON_ASSET,
VISIBILITY_ICON_ASSET,
WIND_SPEED_ICON_ASSET,
weatherKindFromCode,
} from '@/lib/icon-assets';
import type { ForecastDay, WeatherSnapshot } from '@/lib/weather';
@@ -17,38 +18,6 @@ const props = defineProps<{
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 [65, 67, 81, 82].includes(code) ? 'heavy-rain' : '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, 4) ?? []);
const metrics = computed(() => {
@@ -99,10 +68,10 @@ const stateLabel = computed(() => {
return props.locationLabel;
});
const currentWeatherKind = computed(() => weatherKind(props.weather?.weatherCode ?? 3));
const currentWeatherKind = computed(() => weatherKindFromCode(props.weather?.weatherCode ?? 3));
function forecastKind(day: ForecastDay) {
return weatherKind(day.weatherCode);
return weatherKindFromCode(day.weatherCode);
}
</script>
@@ -178,8 +147,11 @@ function forecastKind(day: ForecastDay) {
align-content: stretch;
height: 100%;
gap: 0.72rem;
padding: 1.08rem 1.12rem 0.98rem;
padding: 0;
overflow: hidden;
border: 0;
border-radius: 0;
background: transparent;
}
.weather-card__heading {
@@ -194,16 +166,17 @@ function forecastKind(day: ForecastDay) {
}
.weather-card__title {
font-size: 2.16rem;
font-family: var(--title-font);
font-size: calc(2.16rem * var(--theme-font-scale, 1));
font-weight: 700;
color: #000000;
color: var(--ink);
}
.weather-card__subtitle {
margin-top: 0.14rem;
font-size: 1.12rem;
font-size: calc(1.12rem * var(--theme-font-scale, 1));
line-height: 1.08;
color: #000000;
color: var(--ink);
}
.weather-card__hero {
@@ -213,15 +186,16 @@ function forecastKind(day: ForecastDay) {
gap: 0.8rem;
min-height: 0;
padding: 0.88rem 0.94rem;
border-radius: 1rem;
border-radius: var(--panel-radius);
border: 2px solid var(--frame-stroke);
background: #ffffff;
background: var(--panel-background);
}
.weather-card__hero--placeholder {
justify-content: center;
min-height: 5.75rem;
color: #000000;
font-size: calc(1.12rem * var(--theme-font-scale, 1));
color: var(--ink);
}
.weather-card__hero-main {
@@ -232,16 +206,16 @@ function forecastKind(day: ForecastDay) {
}
.weather-card__temperature {
font-size: 2.8rem;
font-size: calc(2.8rem * var(--theme-font-scale, 1));
line-height: 0.94;
color: #000000;
color: var(--ink);
}
.weather-card__condition {
margin-top: 0.18rem;
font-size: 1.36rem;
font-size: calc(1.36rem * var(--theme-font-scale, 1));
line-height: 1.05;
color: #000000;
color: var(--ink);
}
.weather-card__facts {
@@ -256,8 +230,8 @@ function forecastKind(day: ForecastDay) {
display: inline-flex;
align-items: flex-start;
gap: 0.45rem;
color: #000000;
font-size: 1.08rem;
color: var(--ink);
font-size: calc(1.08rem * var(--theme-font-scale, 1));
line-height: 1.06;
min-width: 0;
}
@@ -282,43 +256,60 @@ function forecastKind(day: ForecastDay) {
.weather-card__forecast {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: stretch;
gap: 0.46rem;
min-height: 0;
}
.forecast-pill {
display: grid;
grid-template-rows: auto auto auto;
justify-items: center;
align-content: center;
gap: 0.34rem;
justify-content: center;
gap: 0.16rem;
min-height: 0;
padding: 0.68rem 0.28rem;
border-radius: 1rem;
padding: 0.4rem 0.16rem;
border-radius: var(--panel-radius);
border: 2px solid var(--frame-stroke);
background: #ffffff;
background: var(--panel-background);
text-align: center;
overflow: hidden;
}
.forecast-pill__label,
.forecast-pill__temp {
margin: 0;
max-width: 100%;
}
.forecast-pill__label {
font-size: 1rem;
line-height: 1;
color: #000000;
font-size: calc(1rem * var(--theme-font-scale, 1) * var(--forecast-pill-scale, 1));
line-height: 1.05;
color: var(--ink);
white-space: nowrap;
}
.forecast-pill__temp {
font-size: 1.24rem;
display: flex;
align-items: baseline;
justify-content: center;
gap: 0.08rem;
font-size: calc(1.24rem * var(--theme-font-scale, 1) * var(--forecast-pill-scale, 1));
line-height: 1;
color: #000000;
color: var(--ink);
white-space: nowrap;
}
.forecast-pill__temp span {
margin-left: 0.12rem;
font-size: 1rem;
color: #000000;
margin-left: 0;
font-size: calc(1rem * var(--theme-font-scale, 1) * var(--forecast-pill-scale, 1));
color: var(--ink);
}
.forecast-pill :deep(.glyph--large) {
width: calc(2.1rem * var(--forecast-pill-scale, 1));
height: calc(2.1rem * var(--forecast-pill-scale, 1));
}
.weather-card__metrics {
@@ -335,10 +326,10 @@ function forecastKind(day: ForecastDay) {
gap: 0.3rem;
min-height: 0;
padding: 0.68rem 0.74rem;
border-radius: 0.95rem;
border-radius: var(--panel-radius);
border: 2px solid var(--frame-stroke);
background: #ffffff;
color: #000000;
background: var(--panel-background);
color: var(--ink);
}
.metric-pill__label,
@@ -350,14 +341,14 @@ function forecastKind(day: ForecastDay) {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.88rem;
font-size: calc(0.88rem * var(--theme-font-scale, 1));
line-height: 1.06;
}
.metric-pill__value {
font-size: 1.34rem;
font-size: calc(1.34rem * var(--theme-font-scale, 1));
line-height: 1.02;
color: #000000;
color: var(--ink);
}
.weather-card :deep(.glyph--large) {

View File

@@ -0,0 +1,119 @@
import themesConfigData from '../../config/themes.json';
export type DashboardOrientation = 'portrait' | 'landscape';
export interface DashboardPreviewPalette {
pageBackground: string;
paper: string;
panelBackground: string;
frameStroke: string;
frameStrokeStrong: string;
frameMuted: string;
mutedInk: string;
badgeFill: string;
bodyFont: string;
displayFont: string;
titleFont: string;
cardRadius: string;
panelRadius: string;
}
export interface DashboardClockConfig {
x: number;
y: number;
width: number;
height: number;
faceRadiusRatio: number;
faceStroke: number;
tickOuterInset: number;
majorTickOuterInset?: number;
minorTickOuterInset?: number;
majorTickLength: number;
minorTickLength: number;
majorTickThickness: number;
minorTickThickness: number;
hourLengthRatio: number;
hourBackLengthRatio?: number;
minuteLengthRatio: number;
minuteBackLengthRatio?: number;
hourThickness: number;
minuteThickness: number;
centerRadius: number;
}
export interface DashboardThemeVariant {
devicePlacement: string;
viewport: {
width: number;
height: number;
};
backgroundPath: string;
clock: DashboardClockConfig;
}
export interface DashboardThemeDefinition {
id: string;
label: string;
preview: DashboardPreviewPalette;
variants: Record<DashboardOrientation, DashboardThemeVariant>;
}
interface DashboardThemesConfig {
defaultThemeId: string;
defaultOrientation: DashboardOrientation;
themes: DashboardThemeDefinition[];
}
const themesConfig = themesConfigData as DashboardThemesConfig;
const orientationSet = new Set<DashboardOrientation>(['portrait', 'landscape']);
const themeMap = new Map(themesConfig.themes.map((theme) => [theme.id, theme]));
const fallbackTheme = themesConfig.themes[0];
if (!fallbackTheme) {
throw new Error('themes.json 未定义任何主题');
}
export const DASHBOARD_THEMES = themesConfig.themes;
export const DEFAULT_THEME_ID = themesConfig.defaultThemeId;
export const DEFAULT_ORIENTATION = themesConfig.defaultOrientation;
export function resolveDashboardThemeId(search: string): string {
const themeId = new URLSearchParams(search).get('theme');
if (themeId && themeMap.has(themeId)) {
return themeId;
}
return DEFAULT_THEME_ID;
}
export function resolveDashboardOrientation(search: string): DashboardOrientation {
const orientation = new URLSearchParams(search).get('orientation');
if (orientation && orientationSet.has(orientation as DashboardOrientation)) {
return orientation as DashboardOrientation;
}
return DEFAULT_ORIENTATION;
}
export function getDashboardTheme(themeId: string): DashboardThemeDefinition {
return themeMap.get(themeId) ?? themeMap.get(DEFAULT_THEME_ID) ?? fallbackTheme;
}
export function getDashboardVariant(themeId: string, orientation: DashboardOrientation): DashboardThemeVariant {
const theme = getDashboardTheme(themeId);
return theme.variants[orientation];
}
export function buildDashboardSearch(params: {
mode: string;
themeId: string;
orientation: DashboardOrientation;
}) {
const searchParams = new URLSearchParams();
searchParams.set('mode', params.mode);
searchParams.set('theme', params.themeId);
searchParams.set('orientation', params.orientation);
return `?${searchParams.toString()}`;
}

View File

@@ -41,6 +41,38 @@ export function weatherIconForKind(kind: WeatherIconKind) {
return WEATHER_ICON_MAP[kind];
}
export function weatherKindFromCode(code: number): WeatherIconKind {
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 [65, 67, 81, 82].includes(code) ? 'heavy-rain' : 'rain';
}
if ([71, 73, 75, 77, 85, 86].includes(code)) {
return 'snow';
}
if ([95, 96, 99].includes(code)) {
return 'storm';
}
return 'cloudy';
}
export const QUOTE_ICON_ASSET = bookIcon;
export const HUMIDITY_ICON_ASSET = humidityIcon;
export const WIND_SPEED_ICON_ASSET = windSpeedIcon;

View File

@@ -27,11 +27,26 @@ export interface WeatherSnapshot {
}
const DEFAULT_LOCATION: LocationCoordinates = {
latitude: 31.2304,
longitude: 121.4737,
label: '上海',
latitude: 30.274084,
longitude: 120.15507,
label: '杭州',
};
interface ReverseGeocodeResponse {
address?: {
city?: string;
town?: string;
municipality?: string;
county?: string;
city_district?: string;
suburb?: string;
village?: string;
hamlet?: string;
state_district?: string;
state?: string;
};
}
const WEATHER_CODE_LABELS: Record<number, string> = {
0: '晴朗',
1: '晴间多云',
@@ -119,6 +134,65 @@ export function weatherCodeToLabel(code: number) {
return WEATHER_CODE_LABELS[code] ?? '天气';
}
function normalizeLocationLabel(value: string) {
return value.trim().replace(/(|||||)$/u, '');
}
function pickLocationLabel(payload: ReverseGeocodeResponse) {
const address = payload.address;
if (!address) {
return null;
}
const rawLabel =
address.city ||
address.town ||
address.municipality ||
address.county ||
address.city_district ||
address.suburb ||
address.village ||
address.hamlet ||
address.state_district ||
address.state;
return rawLabel ? normalizeLocationLabel(rawLabel) : null;
}
async function reverseGeocodeLocation(latitude: number, longitude: number) {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 3000);
const params = new URLSearchParams({
lat: String(latitude),
lon: String(longitude),
format: 'jsonv2',
addressdetails: '1',
layer: 'address',
zoom: '10',
'accept-language': 'zh-CN',
});
try {
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?${params.toString()}`, {
signal: controller.signal,
});
if (!response.ok) {
return null;
}
const payload = (await response.json()) as ReverseGeocodeResponse;
return pickLocationLabel(payload);
} catch (error) {
console.warn('逆地理编码失败,继续使用回退地点', error);
return null;
} finally {
window.clearTimeout(timeoutId);
}
}
export async function resolveLocation(): Promise<LocationCoordinates> {
if (!('geolocation' in navigator)) {
return DEFAULT_LOCATION;
@@ -126,11 +200,14 @@ export async function resolveLocation(): Promise<LocationCoordinates> {
return new Promise((resolve) => {
navigator.geolocation.getCurrentPosition(
(position) => {
async (position) => {
// 定位坐标拿到后再做一次城市级逆地理编码,避免界面上只显示“当前位置”。
const label = await reverseGeocodeLocation(position.coords.latitude, position.coords.longitude);
resolve({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
label: '当前位置',
label: label ?? '当前位置',
});
},
() => resolve(DEFAULT_LOCATION),

View File

@@ -8,10 +8,20 @@
background: #ffffff;
--dashboard-width: 1072px;
--dashboard-height: 1448px;
--dashboard-aspect: 1072 / 1448;
--ink: #000000;
--muted-ink: #4c4c4c;
--paper: #ffffff;
--panel-background: #ffffff;
--page-background: #ffffff;
--frame-stroke: #8b6b47;
--frame-stroke-strong: #6f5235;
--frame-muted: rgba(139, 107, 71, 0.35);
--badge-fill: #faf6ef;
--display-font: 'Iowan Old Style', 'Baskerville', serif;
--title-font: 'Hiragino Sans GB', 'PingFang SC', 'Noto Sans SC', sans-serif;
--card-radius: 2rem;
--panel-radius: 1.25rem;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
@@ -33,6 +43,7 @@ body,
body {
min-height: 100vh;
font-family: var(--body-font, 'Hiragino Sans GB', 'PingFang SC', 'Noto Sans SC', sans-serif);
}
img {
@@ -44,34 +55,127 @@ img {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: #ffffff;
padding: 1.1rem;
background: var(--page-background);
}
.page-shell--clock-face {
background: #ffffff;
background: var(--page-background);
}
.page-shell--background {
padding: 0;
width: var(--dashboard-width);
height: var(--dashboard-height);
min-height: 0;
align-items: stretch;
justify-content: flex-start;
overflow: hidden;
}
.page-stack {
display: grid;
gap: 0.9rem;
justify-items: center;
width: 100%;
}
.page-shell--background .page-stack {
gap: 0;
width: var(--dashboard-width);
height: var(--dashboard-height);
justify-items: stretch;
}
.preview-toolbar {
display: inline-flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.75rem;
padding: 0.65rem 0.8rem;
border: 1.5px solid var(--frame-stroke);
border-radius: 999px;
background: var(--paper);
}
.preview-toolbar__field {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.preview-toolbar__label {
font-family: var(--title-font);
font-size: calc(0.88rem * var(--theme-font-scale, 1));
color: var(--muted-ink);
}
.preview-toolbar__select {
min-width: 9.2rem;
padding: 0.36rem 0.7rem;
border: 1.5px solid var(--frame-stroke);
border-radius: 999px;
background: var(--paper);
color: var(--ink);
font: inherit;
font-size: calc(1rem * var(--theme-font-scale, 1));
}
.dashboard-frame {
width: min(100vw, var(--dashboard-width));
aspect-ratio: 1072 / 1448;
aspect-ratio: var(--dashboard-aspect);
background: var(--paper);
overflow: hidden;
}
.page-shell--background .dashboard-frame {
width: var(--dashboard-width);
height: var(--dashboard-height);
aspect-ratio: auto;
flex: 0 0 auto;
}
.dashboard-grid {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
grid-template-rows: minmax(0, 1fr) 168px;
gap: 1.25rem;
gap: 1.15rem;
height: 100%;
padding: 1.4rem;
padding: 1.3rem;
align-content: start;
align-items: stretch;
}
.dashboard-grid--portrait {
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
grid-template-rows: minmax(0, 1fr) 168px;
}
.dashboard-grid--portrait .dashboard-grid__quote {
grid-column: 1 / -1;
}
.dashboard-grid--landscape {
grid-template-columns: minmax(0, 1.24fr) minmax(21rem, 0.76fr);
grid-template-rows: minmax(0, 1fr) 216px;
}
.dashboard-grid--landscape .dashboard-grid__calendar {
grid-column: 1;
grid-row: 1 / span 2;
}
.dashboard-grid--landscape .dashboard-grid__weather {
grid-column: 2;
grid-row: 1;
}
.dashboard-grid--landscape .dashboard-grid__quote {
grid-column: 2;
grid-row: 2;
}
.card {
min-height: 0;
border-radius: 2rem;
border-radius: var(--card-radius);
background: var(--paper);
border: 2px solid var(--frame-stroke);
box-shadow: none;
@@ -84,6 +188,56 @@ img {
aspect-ratio: 1;
}
.dashboard-grid--landscape .calendar-card {
gap: 0.82rem;
}
.dashboard-grid--landscape .calendar-card__day {
font-size: calc(6.2rem * var(--theme-font-scale, 1));
}
.dashboard-grid--landscape .weather-card {
grid-template-rows: auto minmax(0, 1fr) minmax(0, 0.86fr) minmax(0, 0.9fr);
gap: 0.58rem;
}
.dashboard-grid--landscape .weather-card__title {
font-size: calc(1.92rem * var(--theme-font-scale, 1));
}
.dashboard-grid--landscape .weather-card__subtitle {
font-size: calc(1rem * var(--theme-font-scale, 1));
}
.dashboard-grid--landscape .weather-card__temperature {
font-size: calc(2.4rem * var(--theme-font-scale, 1));
}
.dashboard-grid--landscape .weather-card__condition {
font-size: calc(1.2rem * var(--theme-font-scale, 1));
}
.dashboard-grid--landscape .weather-card__forecast {
gap: 0.36rem;
}
.dashboard-grid--landscape .forecast-pill {
padding: 0.32rem 0.12rem;
}
.dashboard-grid--landscape .forecast-pill__label {
font-size: calc(0.92rem * var(--theme-font-scale, 1) * var(--forecast-pill-scale, 1));
}
.dashboard-grid--landscape .forecast-pill__temp {
font-size: calc(1.1rem * var(--theme-font-scale, 1) * var(--forecast-pill-scale, 1));
}
.dashboard-grid--landscape .quote-card {
gap: 0.5rem;
padding: 0.92rem 1rem;
}
@media (max-width: 1100px) {
.page-shell {
padding: 0.75rem;
@@ -101,6 +255,14 @@ img {
padding: 0.9rem;
}
.dashboard-grid--portrait .dashboard-grid__quote,
.dashboard-grid--landscape .dashboard-grid__calendar,
.dashboard-grid--landscape .dashboard-grid__weather,
.dashboard-grid--landscape .dashboard-grid__quote {
grid-column: auto;
grid-row: auto;
}
.page-shell {
align-items: stretch;
}