update at 2026-03-17 10:37:27
This commit is contained in:
306
calendar/config/themes.json
Normal file
306
calendar/config/themes.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
175
calendar/scripts/export-theme-backgrounds.sh
Normal file
175
calendar/scripts/export-theme-backgrounds.sh
Normal 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
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
164
calendar/src/components/SimpleAnalogClock.vue
Normal file
164
calendar/src/components/SimpleAnalogClock.vue
Normal 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>
|
||||
656
calendar/src/components/SimpleDashboard.vue
Normal file
656
calendar/src/components/SimpleDashboard.vue
Normal 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>
|
||||
@@ -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) {
|
||||
|
||||
119
calendar/src/lib/dashboard-theme.ts
Normal file
119
calendar/src/lib/dashboard-theme.ts
Normal 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()}`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user