yangkaixiang 6 天之前
當前提交
59390d2705
共有 18 個文件被更改,包括 3797 次插入0 次删除
  1. 12 0
      .gitignore
  2. 287 0
      DESIGN.md
  3. 1871 0
      package-lock.json
  4. 19 0
      package.json
  5. 430 0
      public/styles.css
  6. 144 0
      src/db.js
  7. 35 0
      src/holidayPrompt.js
  8. 183 0
      src/index.js
  9. 248 0
      src/mqttService.js
  10. 135 0
      src/scheduleService.js
  11. 64 0
      src/scheduler.js
  12. 72 0
      views/holidays.ejs
  13. 45 0
      views/index.ejs
  14. 41 0
      views/logs.ejs
  15. 16 0
      views/partials/nav.ejs
  16. 14 0
      views/partials/occurrences.ejs
  17. 136 0
      views/schedules.ejs
  18. 45 0
      views/settings.ejs

+ 12 - 0
.gitignore

@@ -0,0 +1,12 @@
+node_modules/
+data/
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+.env
+.env.*
+!.env.example
+.DS_Store
+Thumbs.db

+ 287 - 0
DESIGN.md

@@ -0,0 +1,287 @@
+## Overview
+
+Apple's web presence is a masterclass in **reverent product photography framed by near-invisible UI**. Every page is a stack of edge-to-edge product "tiles" — alternating light and dark canvases, each centered on a hero headline, a one-line tagline, two tiny blue pill CTAs, and an impossibly crisp product render. Nothing competes with the product. Typography is confident but quiet; color is either pure white, an off-white parchment, or a near-black tile; interactive elements are a single, quiet blue.
+
+Density is unusually low even by contemporary SaaS standards. Each tile occupies roughly one viewport, and there is no decorative chrome — no borders, no gradients, no decorative frames, no shadows on headlines. Elevation appears only when a product image rests on a surface (a single soft `rgba(0, 0, 0, 0.22) 3px 5px 30px` drop for visual weight). The result is a catalog that feels more like a museum gallery: the wall disappears and the artifact takes over.
+
+Store and shop surfaces retain the same chassis but switch modes. The product configurator (iPhone 17 Pro, accessories grid) introduces a tight grid of white utility cards at `{rounded.lg}` (18px) radius with a thin border, paired with a persistent thin sub-nav strip. The environment page leans darker and more editorial. Across all five surfaces the typographic system, spacing rhythm, and the single blue accent are consistent — this is one design language expressed at different volumes.
+
+**Key Characteristics:**
+- Photography-first presentation; UI recedes so the product can speak.
+- Alternating full-bleed tile sections: white/parchment ↔ near-black, with the color change itself acting as the section divider.
+- Single blue accent (`{colors.primary}` — #0066cc) carries every interactive element. No second brand color exists.
+- Two button grammars: tiny blue pill CTAs (`{rounded.pill}`) and compact utility rects (`{rounded.sm}`).
+- SF Pro Display + SF Pro Text — negative letter-spacing at display sizes for the signature "Apple tight" headline feel.
+- Whisper-soft elevation used only when a product image needs to breathe — exactly one drop-shadow in the entire system.
+- Tight two-row nav: slim `{component.global-nav}` + product-specific `{component.sub-nav-frosted}` with persistent right-aligned primary CTA.
+- Section rhythm across multiple pages: light hero → dark product tile → light utility tile → dark tile → parchment footer — a predictable pulse.
+
+## Colors
+
+> **Source pages analyzed:** homepage, environment, store, iPhone 17 Pro buy page, accessories index. The color system is identical across all five surfaces; only the surface-mode mix differs.
+
+### Brand & Accent
+- **Action Blue** (`{colors.primary}` — #0066cc): The single brand-level interactive color. All text links, all blue pill CTAs ("Learn more", "Buy"), and the focus ring root. This is Apple's quiet but universal "click me" signal. Press state shifts to a slightly darker variant via the active scale transform rather than a hex change.
+- **Focus Blue** (`{colors.primary-focus}` — #0071e3): A marginally brighter sibling of Action Blue, reserved for the keyboard focus ring on buttons (`outline: 2px solid`).
+- **Sky Link Blue** (`{colors.primary-on-dark}` — #2997ff): A brighter blue used on dark surfaces for in-copy links and inline callouts, where Action Blue would disappear against the tile background.
+
+### Surface
+- **Pure White** (`{colors.canvas}` — #ffffff): The dominant canvas. Content, utility cards, store tiles, configurator grids.
+- **Parchment** (`{colors.canvas-parchment}` — #f5f5f7): The signature Apple off-white. Used for alternating light tiles, footer region, and the default page canvas in store utility sections. Just different enough from white to create rhythm.
+- **Pearl Button** (`{colors.surface-pearl}` — #fafafc): A near-white used as the fill for secondary "ghost" buttons — lighter than the parchment canvas so the button still reads as a button against `{colors.canvas-parchment}`.
+- **Near-Black Tile 1** (`{colors.surface-tile-1}` — #272729): The primary dark-tile surface on the homepage product grid.
+- **Near-Black Tile 2** (`{colors.surface-tile-2}` — #2a2a2c): A micro-step lighter — used where a dark tile sits directly above or below Tile 1 to create the faintest separation.
+- **Near-Black Tile 3** (`{colors.surface-tile-3}` — #252527): A micro-step darker — used at the bottom of the stack and in embedded video/player frames.
+- **Pure Black** (`{colors.surface-black}` — #000000): Reserved for true void — video player backgrounds, edge-to-edge photographic overlays, the global nav bar background.
+- **Translucent Chip Gray** (`{colors.surface-chip-translucent}` — #d2d2d7): The base hex of the translucent gray chip used over photography for circular control buttons. In production, applied at ~64% alpha as `rgba(210, 210, 215, 0.64)`.
+
+### Text
+- **Near-Black Ink** (`{colors.ink}` — #1d1d1f): The voice of every headline, every body paragraph, and the dark utility button's fill. Chosen instead of pure black to keep the page feeling photographic rather than printed.
+- **Body** (`{colors.body}` — #1d1d1f): Same hex as ink — Apple uses one near-black tone for all text on light surfaces.
+- **Body On Dark** (`{colors.body-on-dark}` — #ffffff): All text on dark tiles and on the global nav bar.
+- **Body Muted** (`{colors.body-muted}` — #cccccc): Secondary copy on dark tiles where pure white would be too loud.
+- **Ink Muted 80** (`{colors.ink-muted-80}` — #333333): Body text on the white Pearl Button surface — slightly softer than pure black.
+- **Ink Muted 48** (`{colors.ink-muted-48}` — #7a7a7a): Disabled button text and legal fine-print.
+
+### Hairlines & Borders
+- **Divider Soft** (`{colors.divider-soft}` — #f0f0f0): The "border" tone on secondary buttons — functions as a ring shadow rather than a hard line. In production, often applied as `rgba(0, 0, 0, 0.04)`.
+- **Hairline** (`{colors.hairline}` — #e0e0e0): The 1px hairline border on store utility cards and configurator chips.
+
+### Brand Gradient
+**No decorative gradients.** Atmospheric depth on product photography (the iPhone 17 Pro camera plate, the Apple Watch bands, AirPods reflections) is inherent to the imagery, not a CSS gradient overlay. The environment page's hero uses photographic atmosphere (mountain vista at dawn) but no gradient tokens are defined. Apple is the rare luxury-brand site with zero gradient-based design tokens.
+
+## Typography
+
+### Font Family
+- **Display**: `SF Pro Display, system-ui, -apple-system, sans-serif` — Apple's proprietary display face, optimized for sizes ≥ 19px. Defines the voice of every headline.
+- **Body / UI**: `SF Pro Text, system-ui, -apple-system, sans-serif` — the text-optimized variant used for body copy, captions, buttons, and links below 20px.
+- **OpenType features**: `font-variant-numeric: numerator` is enabled on numeric links (pricing tables, spec sheets). Display sizes rely on tight tracking rather than contextual ligatures.
+
+### Hierarchy
+
+| Token | Size | Weight | Line Height | Letter Spacing | Use |
+|---|---|---|---|---|---|
+| `{typography.hero-display}` | 56px | 600 | 1.07 | -0.28px | Hero headline; the signature "Apple tight" tracking |
+| `{typography.display-lg}` | 40px | 600 | 1.10 | 0 | Tile headlines atop every product tile |
+| `{typography.display-md}` | 34px | 600 | 1.47 | -0.374px | Section heads (SF Pro Text at display proportions) |
+| `{typography.lead}` | 28px | 400 | 1.14 | 0.196px | Product tile subcopy |
+| `{typography.lead-airy}` | 24px | 300 | 1.5 | 0 | Environment-page lead paragraphs (the rare weight 300) |
+| `{typography.tagline}` | 21px | 600 | 1.19 | 0.231px | Sub-tile tagline; sub-nav category name |
+| `{typography.body-strong}` | 17px | 600 | 1.24 | -0.374px | Inline strong emphasis |
+| `{typography.body}` | 17px | 400 | 1.47 | -0.374px | Default paragraph |
+| `{typography.dense-link}` | 17px | 400 | 2.41 | 0 | Footer / store utility link lists (relaxed leading) |
+| `{typography.caption}` | 14px | 400 | 1.43 | -0.224px | Secondary captions, button text |
+| `{typography.caption-strong}` | 14px | 600 | 1.29 | -0.224px | Emphasized captions |
+| `{typography.button-large}` | 18px | 300 | 1.0 | 0 | Store hero CTAs (the rare weight 300) |
+| `{typography.button-utility}` | 14px | 400 | 1.29 | -0.224px | Utility/nav button labels |
+| `{typography.fine-print}` | 12px | 400 | 1.0 | -0.12px | Fine-print, footer body |
+| `{typography.micro-legal}` | 10px | 400 | 1.3 | -0.08px | Micro legal disclaimers |
+| `{typography.nav-link}` | 12px | 400 | 1.0 | -0.12px | Global nav menu items |
+
+### Principles
+
+- **Negative letter-spacing at display sizes.** Every headline at 17px and up carries a slight tracking tighten (`-0.12 → -0.374px`). This produces the iconic "Apple tight" headline cadence. Never used at 12px or below.
+- **Body copy at 17px, not 16px.** Apple breaks the SaaS convention and runs paragraph text at 17px. The extra pixel gives the page an unmistakable "reading, not scanning" pace.
+- **Weight 300 is real and rare.** Used deliberately on a handful of large-size reads (`{typography.button-large}` at 18px/300 and `{typography.lead-airy}` at 24px/300). It's not an accident — it's a light-atmosphere cue reserved for moments where the content should feel airy.
+- **Weight 600, not 700, for headlines.** Apple's headlines sit at weight 600. Weight 700 is used sparingly for `{typography.tagline}` (21px) when a touch more assertion is needed.
+- **Line-height is context-specific.** Display sizes use 1.07–1.19 (tight). Body uses 1.47. Utility link stacks in the footer/store use an unusually relaxed 2.41 (`{typography.dense-link}`). The 2.41 is not a bug — it's how the footer's dense link columns breathe.
+- **Weight 500 is deliberately absent.** The ladder is 300 / 400 / 600 / 700. Mid-weight readings always use 600.
+
+### Note on Font Substitutes
+SF Pro is Apple's proprietary system font. When building off-system:
+
+- Use `system-ui, -apple-system, BlinkMacSystemFont` as the first stack entry — on macOS/iOS/Safari this resolves to the real SF Pro.
+- For non-Apple platforms, **Inter** (Google Fonts, variable) is the closest open-source equivalent. Inter at weight 600 with `font-feature-settings: "ss03"` approximates SF Pro's rounded "a" character.
+- Nudge `letter-spacing` down by `-0.01em` on display sizes to re-create the Apple tight feel; Inter's default tracking runs slightly wider than SF Pro.
+- For body text, tighten line-height by `0.03` (from 1.47 → 1.44) when substituting Inter — Inter's taller x-height needs less leading.
+
+## Layout
+
+### Spacing System
+- **Base unit:** 8px. Sub-base values (2, 4, 5, 6, 7) are used for tight typographic adjustments; structural layout snaps to 8/12/16/20/24.
+- **Tokens:** `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 17px · `{spacing.lg}` 24px · `{spacing.xl}` 32px · `{spacing.xxl}` 48px · `{spacing.section}` 80px.
+- **Section vertical padding:** `{spacing.section}` (80px) inside a product tile; tiles stack edge-to-edge with 0 gap (the color change provides the break).
+- **Card padding:** `{spacing.lg}` (24px) inside utility grid cards.
+- **Button padding:** 8–11px vertical, 15–22px horizontal.
+- **Universal rhythm constants:** the 17px body line-height multiplier (~25px line) and 21px tagline size show up on every analyzed page.
+
+### Grid & Container
+- **Max content width:** ~980px on text-heavy sections (environment), ~1440px on product grids (store, accessories), full-bleed for product tiles (homepage).
+- **Column patterns:** 3 to 5 column utility card grid on store/accessories; 2-column side-by-side tiles on homepage occasional sections; single-column centered stack on product tile heroes.
+- **Gutters:** 20–24px between cards in a utility grid.
+
+### Whitespace Philosophy
+Apple's whitespace is the product's pedestal. Every tile begins with at least 64px of air above its headline and 48–64px below. Product renders are never crowded; the nearest content to a product image is at least 40px away. The footer is the only area that breaks this — there, Apple goes deliberately dense to make the full information architecture visible at a glance.
+
+## Elevation & Depth
+
+| Level | Treatment | Use |
+|---|---|---|
+| Flat | No shadow, no border | Full-bleed tiles, global nav, footer, body sections |
+| Soft hairline | 1px `rgba(0, 0, 0, 0.08)` border | Utility cards, sub-nav frosted-glass separator |
+| Backdrop blur | `backdrop-filter: blur(N)` on Parchment 80% | Sub-nav and the iPhone buy floating sticky bar |
+| Product shadow | `rgba(0, 0, 0, 0.22) 3px 5px 30px 0` | Product renders resting on a surface (the only true "shadow" in the system) |
+
+**Shadow philosophy.** Apple uses **exactly one** drop-shadow, and it is applied to photographic product imagery — never to cards, never to buttons, never to text. Elevation in the UI comes from (a) surface-color change (light tile ↔ dark tile) and (b) backdrop-blur on sticky bars. The single shadow is about giving the product weight, not about UI hierarchy.
+
+### Decorative Depth
+- **Atmospheric imagery** on the environment page (photographic vista) supplies mood; no CSS gradient involved.
+- **Edge-to-edge tile alternation** creates rhythm without borders or shadows — the color change itself is the divider.
+- **Backdrop-filter blur** on `{component.sub-nav-frosted}` and `{component.floating-sticky-bar}` creates a "floating over content" effect that's functional, not decorative.
+
+## Shapes
+
+### Border Radius Scale
+
+| Token | Value | Use |
+|---|---|---|
+| `{rounded.none}` | 0px | Full-bleed product tiles (no corner rounding) |
+| `{rounded.xs}` | 5px | Inline links when styled as subtle chips (rare) |
+| `{rounded.sm}` | 8px | Dark utility buttons (Sign In, Bag), inline card imagery |
+| `{rounded.md}` | 11px | White Pearl Button capsules |
+| `{rounded.lg}` | 18px | Store utility cards, accessories grid cards |
+| `{rounded.pill}` | 9999px | Primary blue pill CTAs, sub-nav buy button, configurator option chips, search input — the signature Apple pill |
+| `{rounded.full}` | 9999px / 50% | Circular control chips floating over photography |
+
+### Photography Geometry
+- **Hero imagery**: full-bleed, 21:9 or taller on the homepage; 16:9 on environment and shop pages. Product renders are photographic-realistic, often shot on a tinted surface that becomes the tile background.
+- **Product renders**: PNG/WebP with transparency; rest on a surface tile and pick up the system shadow.
+- **Accessory grid**: square 1:1 crops at `{rounded.lg}` (18px) radius, light neutral backgrounds, product centered with 20–40px internal padding.
+- **No rounded imagery in hero tiles** — images are full-bleed rectangular. Rounding (`{rounded.sm}`, `{rounded.lg}`) appears only on inline card imagery.
+- Lazy-loading via responsive `srcset` and `sizes` across all breakpoints; CDN-optimized WebP.
+
+## Components
+
+### Top Navigation
+
+**`global-nav`** — Persistent, ultra-thin black nav bar pinned to the top of every page. Background `{colors.surface-black}`, height 44px, text `{colors.on-dark}` in `{typography.nav-link}` (12px / 400 / -0.12px tracking). Links are quiet, spaced ~20px apart, running edge-to-edge across the top. Right-aligned cluster: Search, Bag icons — always visible. On mobile, collapses to hamburger at ~834px and the Apple logo centers.
+
+**`sub-nav-frosted`** — Surface-specific nav that sticks below the global nav. Background `{colors.canvas-parchment}` at 80% opacity with backdrop-filter blur, creating a frosted-glass effect. Height 52px. Content on left: product category name ("iPhone", "Store", "Accessories") in `{typography.tagline}` (21px / 600). Content right: inline nav links in `{typography.button-utility}` (14px), ending in a persistent `{component.button-primary}` ("Buy") or a utility link.
+
+### Buttons
+
+**`button-primary`** — The signature Apple action. Background `{colors.primary}` (Action Blue #0066cc), text `{colors.on-primary}` in `{typography.body}` (SF Pro Text 17px / 400), rounded `{rounded.pill}` (full pill — capsule-shaped), padding 11px × 22px. The full-pill radius IS the brand action signal.
+- Active state: `{component.button-primary-active}` — `transform: scale(0.95)` (the system-wide micro-interaction).
+- Focus state: `{component.button-primary-focus}` — 2px solid `{colors.primary-focus}` outline.
+
+**`button-secondary-pill`** — Used as the second CTA when two blue pills appear together ("Learn more" / "Buy"). Background transparent, text `{colors.primary}`, 1px solid `{colors.primary}` border, rounded `{rounded.pill}`, padding 11px × 22px. Reads as a "ghost pill."
+
+**`button-dark-utility`** — Global nav actions (Sign In, Bag, language selector). Background `{colors.ink}` (#1d1d1f), text `{colors.on-dark}` in `{typography.button-utility}` (14px / 400 / -0.224px tracking), rounded `{rounded.sm}` (8px), padding 8px × 15px. Active state shrinks via `transform: scale(0.95)`.
+
+**`button-pearl-capsule`** — Product-card secondary button. Background `{colors.surface-pearl}` (#fafafc), text `{colors.ink-muted-80}` in `{typography.caption}` (14px), 3px solid `{colors.divider-soft}` border (functions as a soft ring rather than a visible line), rounded `{rounded.md}` (11px), padding 8px × 14px.
+
+**`button-store-hero`** — A larger primary CTA used on store hero surfaces. Same Action Blue + Paper White as `{component.button-primary}`, but with `{typography.button-large}` (18px / 300 — note the rare weight 300) and slightly more padding (14px × 28px). Used sparingly on the store landing.
+
+**`button-icon-circular`** — Floats over photography. 44 × 44px, background `{colors.surface-chip-translucent}` at ~64% alpha, icon in `{colors.ink}`, rounded `{rounded.full}`. Used for carousel controls, close buttons, and in-image controls (product image thumbnails on the iPhone buy page).
+
+**`text-link`** — Inline body links in `{colors.primary}` (Action Blue). Underlined or non-underlined per context.
+
+**`text-link-on-dark`** — Inline body links on dark tiles in `{colors.primary-on-dark}` (Sky Link Blue #2997ff) — Action Blue would disappear against `{colors.surface-tile-1}`.
+
+### Cards & Containers
+
+**`product-tile-light`** — Full-bleed light tile. Background `{colors.canvas}` (white), text `{colors.ink}`, rounded `{rounded.none}` (0 — tiles touch edges), vertical padding `{spacing.section}` (80px). Centered stack: product name in `{typography.display-lg}` (40px / 600) → one-line tagline in `{typography.lead}` (28px / 400) → two `{component.button-primary}` CTAs ("Learn more" / "Buy") → product render resting on the surface with the system shadow.
+
+**`product-tile-parchment`** — Same as `{component.product-tile-light}` but on `{colors.canvas-parchment}` (#f5f5f7). Used to break two consecutive white tiles.
+
+**`product-tile-dark`** — Full-bleed dark tile. Background `{colors.surface-tile-1}` (#272729), text `{colors.on-dark}`, rounded `{rounded.none}`, vertical padding `{spacing.section}` (80px). Same content stack as the light tile but with `{component.text-link-on-dark}` for inline copy and `{component.button-primary}` (Action Blue still works on the dark surface). Used on the homepage product grid as the alternating dark band.
+
+**`product-tile-dark-2`** — Variant on `{colors.surface-tile-2}` (#2a2a2c). Used where a dark tile sits directly above or below `{component.product-tile-dark}` to create the faintest separation through micro-step lightness change.
+
+**`product-tile-dark-3`** — Variant on `{colors.surface-tile-3}` (#252527). Used at the bottom of the stack and in embedded video/player frames.
+
+**`store-utility-card`** — Used in store grid and accessories grid. Background `{colors.canvas}` (white), 1px solid `{colors.hairline}` border, rounded `{rounded.lg}` (18px), padding `{spacing.lg}` (24px). Top: product image (1:1 crop with `{rounded.sm}` (8px) inner image radius). Below: product name in `{typography.body-strong}` (17px / 600), price in `{typography.body}` (17px / 400), and a `{component.text-link}` ("Buy" or "Learn more"). No shadow by default; product render itself carries the system product-shadow.
+
+**`configurator-option-chip`** — Pill-shaped tappable cell used in the iPhone 17 Pro buy page. Background `{colors.canvas}`, text `{colors.ink}` in `{typography.caption}`, rounded `{rounded.pill}`, padding 12px × 16px. Contains a small product thumbnail + label + price delta. Arranged in a grid of 4–5 options per row.
+
+**`configurator-option-chip-selected`** — Selected state. Border upgrades to 2px solid `{colors.primary-focus}`. Same shape, same content.
+
+**`environment-quote-card`** — A photographic-canvas hero specific to the environment page. Dark photographic backdrop (mountain vista at dawn) with `{colors.surface-tile-1}` as the fallback color, centered white-text headline in `{typography.display-lg}` (40px), small green "Apple 2030" pictographic logo above the headline, single `{component.button-primary}` below. Padding `{spacing.section}` (80px).
+
+**`floating-sticky-bar`** — Floats at the bottom of the viewport on the iPhone 17 Pro buy page during scroll. Background `{colors.canvas-parchment}` at 80% opacity with `backdrop-filter: blur(N)`, height 64px, padding 12px × 32px. Left: running price total in `{typography.body}`. Right: `{component.button-primary}` ("Add to Bag").
+
+### Inputs & Forms
+
+**`search-input`** — The accessories search input. Background `{colors.canvas}`, text `{colors.ink}` in `{typography.body}` (17px), 1px solid `rgba(0, 0, 0, 0.08)` border, rounded `{rounded.pill}` (full pill — search is also pill-shaped, matching the CTA grammar), padding 12px × 20px, height 44px. Leading icon: search glyph at 14px, muted tint.
+
+Error and validation states were not surfaced in the analyzed pages.
+
+### Footer
+
+**`footer`** — Background `{colors.canvas-parchment}` (#f5f5f7), text `{colors.ink-muted-80}`. Link columns in `{typography.dense-link}` (17px / 400 / 2.41 line-height — the relaxed leading is what makes the dense columns scannable). Column headings in `{typography.caption-strong}` (14px / 600). Legal row at the very bottom in `{typography.fine-print}` (12px / 400) with `{colors.ink-muted-48}` text. Vertical padding 64px.
+
+## Do's and Don'ts
+
+### Do
+- Use `{colors.primary}` (Action Blue #0066cc) for every interactive element — links, pill CTAs, focus signals — and nothing else. The single accent is non-negotiable.
+- Set headlines in `{typography.hero-display}` or `{typography.display-lg}` with negative letter-spacing (`-0.28 → -0.374px`) to get the signature "Apple tight" cadence.
+- Run body copy at `{typography.body}` (17px / 400 / 1.47 / -0.374px) — not 16px. The extra pixel defines the brand's reading pace.
+- Alternate `{component.product-tile-light}` (or parchment) and `{component.product-tile-dark}` for full-bleed section rhythm. The color change IS the divider.
+- Reserve `{rounded.pill}` for the primary blue CTA and any other element that should read as an "action" (configurator chips, search input, sticky bar CTA).
+- Apply the single product-shadow (`rgba(0, 0, 0, 0.22) 3px 5px 30px`) only to product renders resting on a surface — never on cards, buttons, or text.
+- Use `transform: scale(0.95)` as the active/press state on every button — it's the system-wide micro-interaction.
+- Keep the global nav `{colors.surface-black}` (true black) — it's the only place pure black appears on most pages.
+
+### Don't
+- Don't introduce a second accent color; every "click me" signal is `{colors.primary}` (Action Blue).
+- Don't add shadows to cards, buttons, or text — shadow is reserved for product imagery.
+- Don't use gradients as decorative backgrounds; atmosphere comes from photography.
+- Don't set body copy at weight 500 — Apple's ladder is 300 / 400 / 600 / 700, with 500 deliberately absent. Body is always 400; strong inline is 600; display is 600.
+- Don't round full-bleed tiles — tiles are rectangular and edge-to-edge; the color change is the divider.
+- Don't tighten line-height below 1.47 for body copy — the editorial leading is part of the brand.
+- Don't mix radii grammars — use `{rounded.sm}` for compact utility, `{rounded.lg}` for utility cards, `{rounded.pill}` for pills, and nothing in between (except the rare `{rounded.md}` Pearl Button).
+- Don't use `{colors.primary-on-dark}` (Sky Link Blue) on light surfaces — it's the dark-tile-only variant. Action Blue is for light surfaces.
+
+## Responsive Behavior
+
+### Breakpoints
+
+| Name | Width | Key Changes |
+|---|---|---|
+| Small phone | ≤ 419px | Single-column tiles; sub-nav collapses to category name + primary CTA only; hero typography drops to 28px |
+| Phone | 420–640px | Single-column stack; product renders scale to 80% of tile width; hero h1 drops to 34px |
+| Large phone | 641–735px | Tiles transition to tighter padding (48px vertical vs 80px); fine-print wraps |
+| Tablet portrait | 736–833px | Global nav collapses to hamburger; sub-nav hides category chips, keeps primary CTA |
+| Tablet landscape | 834–1023px | Global nav returns fully expanded; 3-column utility grids become 2-column |
+| Small desktop | 1024–1068px | Product tiles use 2/3 width with margin gutters; hero h1 stays at 40px |
+| Desktop | 1069–1440px | Full layout; 4–5 column store grids; 1440px content max |
+| Wide desktop | ≥ 1441px | Content locks at 1440px, margins absorb extra width |
+
+The structural breakpoints that matter for agents: 1440px (content lock), 1068px (small-desktop), 833px (tablet landscape switch), 734px (tablet portrait), 640px (phone), 480px (small phone).
+
+### Touch Targets
+- Minimum 44 × 44px. `{component.button-primary}` lands at ~44 × 100px (with the full-pill radius making the visible hit area more generous than the label suggests).
+- `{component.button-icon-circular}` is exactly 44 × 44px.
+- Global nav utility links are smaller (~32 × 80px) — they deliberately sit at a tighter target because they're precision desktop actions, and the mobile hamburger replaces them at ≤ 833px.
+
+### Collapsing Strategy
+- **Global nav**: full horizontal link row on desktop → collapses to Apple logo + hamburger + bag icon at 834px and below.
+- **Sub-nav**: category name + inline links + primary CTA → category name + primary CTA only at mobile; inline links move into a hamburger tray.
+- **Product tiles**: stack from 2-column to 1-column at 834px; vertical padding tightens from 80px → 48px at small-phone.
+- **Utility grids** (store, accessories): 5-col → 4-col (1440px) → 3-col (1068px) → 2-col (834px) → 1-col (640px).
+- **Hero typography**: `{typography.hero-display}` (56px) → `{typography.display-lg}` (40px) at 1068px → 34px at 640px → 28px at 419px.
+
+### Image Behavior
+- All product imagery uses responsive `srcset` with breakpoint-matched crops.
+- Hero photography may switch art direction at mobile (e.g., the environment page's vista crops to a taller aspect ratio on mobile, framing the subject differently).
+- Product renders maintain their 1:1 or 4:3 aspect ratios across breakpoints; only scale changes.
+- Lazy-loading is default; the above-fold hero loads eagerly.
+
+## Iteration Guide
+
+1. Focus on ONE component at a time. Reference its YAML key directly (`{component.product-tile-dark}`, `{component.search-input}`).
+2. Variants of an existing component (`-active`, `-focus`, `-2`, `-3`) live as separate entries in `components:`.
+3. Use `{token.refs}` everywhere — never inline hex.
+4. Never document hover. Default and Active/Pressed states only.
+5. Display headlines stay SF Pro Display 600 with negative letter-spacing. Body stays SF Pro Text 400 at 17px. The boundary is unbreakable.
+6. The single drop-shadow (`rgba(0, 0, 0, 0.22) 3px 5px 30px`) is reserved for product photography only.
+7. When in doubt about emphasis: alternate surface (light → dark tile) before adding chrome.
+
+## Known Gaps
+
+- Form validation and error states were not surfaced on the analyzed pages; only the neutral search input is documented.
+- The homepage's embedded video/player frame uses `{colors.surface-black}`; interior player controls are not documented (they're a platform widget, not a web-design token).
+- Some component imagery is dynamic (rotating product hero) and its specific copy varies per surface — component specs name the structure, not the rotating content.
+- Dark-mode counterparts for store and accessories utility cards were not surfaced on the analyzed pages; the system documented is the daytime/light-dominant variant Apple ships by default.
+- Atmospheric photography (environment page mountain vista) is a content asset, not a design token; the documented `{component.environment-quote-card}` describes the structural surface only.
+- The exact backdrop-filter blur radius on `{component.sub-nav-frosted}` and `{component.floating-sticky-bar}` is platform-dependent; production CSS uses `saturate(180%) blur(20px)` as a typical baseline but the value isn't formalized as a token.

+ 1871 - 0
package-lock.json

@@ -0,0 +1,1871 @@
+{
+  "name": "office-light",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "office-light",
+      "version": "0.1.0",
+      "dependencies": {
+        "better-sqlite3": "^11.8.1",
+        "dayjs": "^1.11.13",
+        "ejs": "^3.1.10",
+        "express": "^4.21.2",
+        "mqtt": "^5.10.3",
+        "node-cron": "^4.2.1"
+      }
+    },
+    "node_modules/@babel/runtime": {
+      "version": "7.29.2",
+      "resolved": "http://192.168.1.103:4873/@babel%2fruntime/-/runtime-7.29.2.tgz",
+      "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "25.6.0",
+      "resolved": "http://192.168.1.103:4873/@types%2fnode/-/node-25.6.0.tgz",
+      "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~7.19.0"
+      }
+    },
+    "node_modules/@types/readable-stream": {
+      "version": "4.0.23",
+      "resolved": "http://192.168.1.103:4873/@types%2freadable-stream/-/readable-stream-4.0.23.tgz",
+      "integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/ws": {
+      "version": "8.18.1",
+      "resolved": "http://192.168.1.103:4873/@types%2fws/-/ws-8.18.1.tgz",
+      "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/abort-controller": {
+      "version": "3.0.0",
+      "resolved": "http://192.168.1.103:4873/abort-controller/-/abort-controller-3.0.0.tgz",
+      "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+      "license": "MIT",
+      "dependencies": {
+        "event-target-shim": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=6.5"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "1.3.8",
+      "resolved": "http://192.168.1.103:4873/accepts/-/accepts-1.3.8.tgz",
+      "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "~2.1.34",
+        "negotiator": "0.6.3"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/array-flatten": {
+      "version": "1.1.1",
+      "resolved": "http://192.168.1.103:4873/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+      "license": "MIT"
+    },
+    "node_modules/async": {
+      "version": "3.2.6",
+      "resolved": "http://192.168.1.103:4873/async/-/async-3.2.6.tgz",
+      "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+      "license": "MIT"
+    },
+    "node_modules/async-function": {
+      "version": "1.0.0",
+      "resolved": "http://192.168.1.103:4873/async-function/-/async-function-1.0.0.tgz",
+      "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/async-generator-function": {
+      "version": "1.0.0",
+      "resolved": "http://192.168.1.103:4873/async-generator-function/-/async-generator-function-1.0.0.tgz",
+      "integrity": "sha512-+NAXNqgCrB95ya4Sr66i1CL2hqLVckAk7xwRYWdcm39/ELQ6YNn1aw5r0bdQtqNZgQpEWzc5yc/igXc7aL5SLA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "http://192.168.1.103:4873/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "license": "MIT"
+    },
+    "node_modules/base64-js": {
+      "version": "1.5.1",
+      "resolved": "http://192.168.1.103:4873/base64-js/-/base64-js-1.5.1.tgz",
+      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/better-sqlite3": {
+      "version": "11.10.0",
+      "resolved": "http://192.168.1.103:4873/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
+      "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "dependencies": {
+        "bindings": "^1.5.0",
+        "prebuild-install": "^7.1.1"
+      }
+    },
+    "node_modules/bindings": {
+      "version": "1.5.0",
+      "resolved": "http://192.168.1.103:4873/bindings/-/bindings-1.5.0.tgz",
+      "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+      "license": "MIT",
+      "dependencies": {
+        "file-uri-to-path": "1.0.0"
+      }
+    },
+    "node_modules/bl": {
+      "version": "6.1.6",
+      "resolved": "http://192.168.1.103:4873/bl/-/bl-6.1.6.tgz",
+      "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/readable-stream": "^4.0.0",
+        "buffer": "^6.0.3",
+        "inherits": "^2.0.4",
+        "readable-stream": "^4.2.0"
+      }
+    },
+    "node_modules/body-parser": {
+      "version": "1.20.5",
+      "resolved": "http://192.168.1.103:4873/body-parser/-/body-parser-1.20.5.tgz",
+      "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "content-type": "~1.0.5",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "~1.2.0",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "on-finished": "~2.4.1",
+        "qs": "~6.15.1",
+        "raw-body": "~2.5.3",
+        "type-is": "~1.6.18",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/body-parser/node_modules/qs": {
+      "version": "6.15.1",
+      "resolved": "http://192.168.1.103:4873/qs/-/qs-6.15.1.tgz",
+      "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/brace-expansion": {
+      "version": "2.1.0",
+      "resolved": "http://192.168.1.103:4873/brace-expansion/-/brace-expansion-2.1.0.tgz",
+      "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/broker-factory": {
+      "version": "3.1.14",
+      "resolved": "http://192.168.1.103:4873/broker-factory/-/broker-factory-3.1.14.tgz",
+      "integrity": "sha512-L45k5HMbPIrMid0nTOZ/UPXG/c0aRuQKVrSDFIb1zOkvfiyHgYmIjc3cSiN1KwQIvRDOtKE0tfb3I9EZ3CmpQQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.29.2",
+        "fast-unique-numbers": "^9.0.27",
+        "tslib": "^2.8.1",
+        "worker-factory": "^7.0.49"
+      }
+    },
+    "node_modules/buffer": {
+      "version": "6.0.3",
+      "resolved": "http://192.168.1.103:4873/buffer/-/buffer-6.0.3.tgz",
+      "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "base64-js": "^1.3.1",
+        "ieee754": "^1.2.1"
+      }
+    },
+    "node_modules/buffer-from": {
+      "version": "1.1.2",
+      "resolved": "http://192.168.1.103:4873/buffer-from/-/buffer-from-1.1.2.tgz",
+      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+      "license": "MIT"
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "http://192.168.1.103:4873/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "http://192.168.1.103:4873/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "http://192.168.1.103:4873/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/chownr": {
+      "version": "1.1.4",
+      "resolved": "http://192.168.1.103:4873/chownr/-/chownr-1.1.4.tgz",
+      "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+      "license": "ISC"
+    },
+    "node_modules/commist": {
+      "version": "3.2.0",
+      "resolved": "http://192.168.1.103:4873/commist/-/commist-3.2.0.tgz",
+      "integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==",
+      "license": "MIT"
+    },
+    "node_modules/concat-stream": {
+      "version": "2.0.0",
+      "resolved": "http://192.168.1.103:4873/concat-stream/-/concat-stream-2.0.0.tgz",
+      "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
+      "engines": [
+        "node >= 6.0"
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "buffer-from": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^3.0.2",
+        "typedarray": "^0.0.6"
+      }
+    },
+    "node_modules/concat-stream/node_modules/readable-stream": {
+      "version": "3.6.2",
+      "resolved": "http://192.168.1.103:4873/readable-stream/-/readable-stream-3.6.2.tgz",
+      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+      "license": "MIT",
+      "dependencies": {
+        "inherits": "^2.0.3",
+        "string_decoder": "^1.1.1",
+        "util-deprecate": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "0.5.4",
+      "resolved": "http://192.168.1.103:4873/content-disposition/-/content-disposition-0.5.4.tgz",
+      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "http://192.168.1.103:4873/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "http://192.168.1.103:4873/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.0.7",
+      "resolved": "http://192.168.1.103:4873/cookie-signature/-/cookie-signature-1.0.7.tgz",
+      "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+      "license": "MIT"
+    },
+    "node_modules/dayjs": {
+      "version": "1.11.20",
+      "resolved": "http://192.168.1.103:4873/dayjs/-/dayjs-1.11.20.tgz",
+      "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "http://192.168.1.103:4873/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/decompress-response": {
+      "version": "6.0.0",
+      "resolved": "http://192.168.1.103:4873/decompress-response/-/decompress-response-6.0.0.tgz",
+      "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+      "license": "MIT",
+      "dependencies": {
+        "mimic-response": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/deep-extend": {
+      "version": "0.6.0",
+      "resolved": "http://192.168.1.103:4873/deep-extend/-/deep-extend-0.6.0.tgz",
+      "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "http://192.168.1.103:4873/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/destroy": {
+      "version": "1.2.0",
+      "resolved": "http://192.168.1.103:4873/destroy/-/destroy-1.2.0.tgz",
+      "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "http://192.168.1.103:4873/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "http://192.168.1.103:4873/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "http://192.168.1.103:4873/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/ejs": {
+      "version": "3.1.10",
+      "resolved": "http://192.168.1.103:4873/ejs/-/ejs-3.1.10.tgz",
+      "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "jake": "^10.8.5"
+      },
+      "bin": {
+        "ejs": "bin/cli.js"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "http://192.168.1.103:4873/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/end-of-stream": {
+      "version": "1.4.5",
+      "resolved": "http://192.168.1.103:4873/end-of-stream/-/end-of-stream-1.4.5.tgz",
+      "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+      "license": "MIT",
+      "dependencies": {
+        "once": "^1.4.0"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "http://192.168.1.103:4873/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "http://192.168.1.103:4873/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "http://192.168.1.103:4873/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "http://192.168.1.103:4873/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "http://192.168.1.103:4873/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/event-target-shim": {
+      "version": "5.0.1",
+      "resolved": "http://192.168.1.103:4873/event-target-shim/-/event-target-shim-5.0.1.tgz",
+      "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/events": {
+      "version": "3.3.0",
+      "resolved": "http://192.168.1.103:4873/events/-/events-3.3.0.tgz",
+      "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.8.x"
+      }
+    },
+    "node_modules/expand-template": {
+      "version": "2.0.3",
+      "resolved": "http://192.168.1.103:4873/expand-template/-/expand-template-2.0.3.tgz",
+      "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+      "license": "(MIT OR WTFPL)",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/express": {
+      "version": "4.22.1",
+      "resolved": "http://192.168.1.103:4873/express/-/express-4.22.1.tgz",
+      "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "~1.3.8",
+        "array-flatten": "1.1.1",
+        "body-parser": "~1.20.3",
+        "content-disposition": "~0.5.4",
+        "content-type": "~1.0.4",
+        "cookie": "~0.7.1",
+        "cookie-signature": "~1.0.6",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "~1.3.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.0",
+        "merge-descriptors": "1.0.3",
+        "methods": "~1.1.2",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "~0.1.12",
+        "proxy-addr": "~2.0.7",
+        "qs": "~6.14.0",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.2.1",
+        "send": "~0.19.0",
+        "serve-static": "~1.16.2",
+        "setprototypeof": "1.2.0",
+        "statuses": "~2.0.1",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/fast-unique-numbers": {
+      "version": "9.0.27",
+      "resolved": "http://192.168.1.103:4873/fast-unique-numbers/-/fast-unique-numbers-9.0.27.tgz",
+      "integrity": "sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.29.2",
+        "tslib": "^2.8.1"
+      },
+      "engines": {
+        "node": ">=18.2.0"
+      }
+    },
+    "node_modules/file-uri-to-path": {
+      "version": "1.0.0",
+      "resolved": "http://192.168.1.103:4873/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+      "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+      "license": "MIT"
+    },
+    "node_modules/filelist": {
+      "version": "1.0.6",
+      "resolved": "http://192.168.1.103:4873/filelist/-/filelist-1.0.6.tgz",
+      "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "minimatch": "^5.0.1"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "1.3.2",
+      "resolved": "http://192.168.1.103:4873/finalhandler/-/finalhandler-1.3.2.tgz",
+      "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "statuses": "~2.0.2",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "http://192.168.1.103:4873/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "0.5.2",
+      "resolved": "http://192.168.1.103:4873/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fs-constants": {
+      "version": "1.0.0",
+      "resolved": "http://192.168.1.103:4873/fs-constants/-/fs-constants-1.0.0.tgz",
+      "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+      "license": "MIT"
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "http://192.168.1.103:4873/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/generator-function": {
+      "version": "2.0.1",
+      "resolved": "http://192.168.1.103:4873/generator-function/-/generator-function-2.0.1.tgz",
+      "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.1",
+      "resolved": "http://192.168.1.103:4873/get-intrinsic/-/get-intrinsic-1.3.1.tgz",
+      "integrity": "sha512-fk1ZVEeOX9hVZ6QzoBNEC55+Ucqg4sTVwrVuigZhuRPESVFpMyXnd3sbXvPOwp7Y9riVyANiqhEuRF0G1aVSeQ==",
+      "license": "MIT",
+      "dependencies": {
+        "async-function": "^1.0.0",
+        "async-generator-function": "^1.0.0",
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "generator-function": "^2.0.0",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "http://192.168.1.103:4873/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/github-from-package": {
+      "version": "0.0.0",
+      "resolved": "http://192.168.1.103:4873/github-from-package/-/github-from-package-0.0.0.tgz",
+      "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+      "license": "MIT"
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "http://192.168.1.103:4873/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "http://192.168.1.103:4873/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.3",
+      "resolved": "http://192.168.1.103:4873/hasown/-/hasown-2.0.3.tgz",
+      "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/help-me": {
+      "version": "5.0.0",
+      "resolved": "http://192.168.1.103:4873/help-me/-/help-me-5.0.0.tgz",
+      "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
+      "license": "MIT"
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "http://192.168.1.103:4873/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "http://192.168.1.103:4873/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/ieee754": {
+      "version": "1.2.1",
+      "resolved": "http://192.168.1.103:4873/ieee754/-/ieee754-1.2.1.tgz",
+      "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "http://192.168.1.103:4873/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ini": {
+      "version": "1.3.8",
+      "resolved": "http://192.168.1.103:4873/ini/-/ini-1.3.8.tgz",
+      "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+      "license": "ISC"
+    },
+    "node_modules/ip-address": {
+      "version": "10.1.1",
+      "resolved": "http://192.168.1.103:4873/ip-address/-/ip-address-10.1.1.tgz",
+      "integrity": "sha512-1FMu8/N15Ck1BL551Jf42NYIoin2unWjLQ2Fze/DXryJRl5twqtwNHlO39qERGbIOcKYWHdgRryhOC+NG4eaLw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 12"
+      }
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "http://192.168.1.103:4873/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/jake": {
+      "version": "10.9.4",
+      "resolved": "http://192.168.1.103:4873/jake/-/jake-10.9.4.tgz",
+      "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "async": "^3.2.6",
+        "filelist": "^1.0.4",
+        "picocolors": "^1.1.1"
+      },
+      "bin": {
+        "jake": "bin/cli.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/js-sdsl": {
+      "version": "4.3.0",
+      "resolved": "http://192.168.1.103:4873/js-sdsl/-/js-sdsl-4.3.0.tgz",
+      "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/js-sdsl"
+      }
+    },
+    "node_modules/lru-cache": {
+      "version": "10.4.3",
+      "resolved": "http://192.168.1.103:4873/lru-cache/-/lru-cache-10.4.3.tgz",
+      "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+      "license": "ISC"
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "http://192.168.1.103:4873/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "http://192.168.1.103:4873/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "1.0.3",
+      "resolved": "http://192.168.1.103:4873/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+      "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/methods": {
+      "version": "1.1.2",
+      "resolved": "http://192.168.1.103:4873/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "http://192.168.1.103:4873/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "http://192.168.1.103:4873/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "http://192.168.1.103:4873/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mimic-response": {
+      "version": "3.1.0",
+      "resolved": "http://192.168.1.103:4873/mimic-response/-/mimic-response-3.1.0.tgz",
+      "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "5.1.9",
+      "resolved": "http://192.168.1.103:4873/minimatch/-/minimatch-5.1.9.tgz",
+      "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/minimist": {
+      "version": "1.2.8",
+      "resolved": "http://192.168.1.103:4873/minimist/-/minimist-1.2.8.tgz",
+      "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/mkdirp-classic": {
+      "version": "0.5.3",
+      "resolved": "http://192.168.1.103:4873/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+      "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+      "license": "MIT"
+    },
+    "node_modules/mqtt": {
+      "version": "5.15.1",
+      "resolved": "http://192.168.1.103:4873/mqtt/-/mqtt-5.15.1.tgz",
+      "integrity": "sha512-V1WnkGuJh3ec9QXzy5Iylw8OOBK+Xu1WhxcQ9mMpLThG+/JZIMV1PgLNRgIiqXhZnvnVLsuyxHl5A/3bHHbcAA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/readable-stream": "^4.0.21",
+        "@types/ws": "^8.18.1",
+        "commist": "^3.2.0",
+        "concat-stream": "^2.0.0",
+        "debug": "^4.4.1",
+        "help-me": "^5.0.0",
+        "lru-cache": "^10.4.3",
+        "minimist": "^1.2.8",
+        "mqtt-packet": "^9.0.2",
+        "number-allocator": "^1.0.14",
+        "readable-stream": "^4.7.0",
+        "rfdc": "^1.4.1",
+        "socks": "^2.8.6",
+        "split2": "^4.2.0",
+        "worker-timers": "^8.0.23",
+        "ws": "^8.18.3"
+      },
+      "bin": {
+        "mqtt": "build/bin/mqtt.js",
+        "mqtt_pub": "build/bin/pub.js",
+        "mqtt_sub": "build/bin/sub.js"
+      },
+      "engines": {
+        "node": ">=16.0.0"
+      }
+    },
+    "node_modules/mqtt-packet": {
+      "version": "9.0.2",
+      "resolved": "http://192.168.1.103:4873/mqtt-packet/-/mqtt-packet-9.0.2.tgz",
+      "integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==",
+      "license": "MIT",
+      "dependencies": {
+        "bl": "^6.0.8",
+        "debug": "^4.3.4",
+        "process-nextick-args": "^2.0.1"
+      }
+    },
+    "node_modules/mqtt-packet/node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "http://192.168.1.103:4873/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/mqtt-packet/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "http://192.168.1.103:4873/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/mqtt/node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "http://192.168.1.103:4873/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/mqtt/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "http://192.168.1.103:4873/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "http://192.168.1.103:4873/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+      "license": "MIT"
+    },
+    "node_modules/napi-build-utils": {
+      "version": "2.0.0",
+      "resolved": "http://192.168.1.103:4873/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+      "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.3",
+      "resolved": "http://192.168.1.103:4873/negotiator/-/negotiator-0.6.3.tgz",
+      "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/node-abi": {
+      "version": "3.89.0",
+      "resolved": "http://192.168.1.103:4873/node-abi/-/node-abi-3.89.0.tgz",
+      "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
+      "license": "MIT",
+      "dependencies": {
+        "semver": "^7.3.5"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/node-cron": {
+      "version": "4.2.1",
+      "resolved": "http://192.168.1.103:4873/node-cron/-/node-cron-4.2.1.tgz",
+      "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/number-allocator": {
+      "version": "1.0.14",
+      "resolved": "http://192.168.1.103:4873/number-allocator/-/number-allocator-1.0.14.tgz",
+      "integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.3.1",
+        "js-sdsl": "4.3.0"
+      }
+    },
+    "node_modules/number-allocator/node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "http://192.168.1.103:4873/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/number-allocator/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "http://192.168.1.103:4873/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "http://192.168.1.103:4873/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "http://192.168.1.103:4873/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "http://192.168.1.103:4873/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "license": "ISC",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "http://192.168.1.103:4873/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "0.1.13",
+      "resolved": "http://192.168.1.103:4873/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+      "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+      "license": "MIT"
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "http://192.168.1.103:4873/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/prebuild-install": {
+      "version": "7.1.3",
+      "resolved": "http://192.168.1.103:4873/prebuild-install/-/prebuild-install-7.1.3.tgz",
+      "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+      "license": "MIT",
+      "dependencies": {
+        "detect-libc": "^2.0.0",
+        "expand-template": "^2.0.3",
+        "github-from-package": "0.0.0",
+        "minimist": "^1.2.3",
+        "mkdirp-classic": "^0.5.3",
+        "napi-build-utils": "^2.0.0",
+        "node-abi": "^3.3.0",
+        "pump": "^3.0.0",
+        "rc": "^1.2.7",
+        "simple-get": "^4.0.0",
+        "tar-fs": "^2.0.0",
+        "tunnel-agent": "^0.6.0"
+      },
+      "bin": {
+        "prebuild-install": "bin.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/process": {
+      "version": "0.11.10",
+      "resolved": "http://192.168.1.103:4873/process/-/process-0.11.10.tgz",
+      "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6.0"
+      }
+    },
+    "node_modules/process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "http://192.168.1.103:4873/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+      "license": "MIT"
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "http://192.168.1.103:4873/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/pump": {
+      "version": "3.0.4",
+      "resolved": "http://192.168.1.103:4873/pump/-/pump-3.0.4.tgz",
+      "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+      "license": "MIT",
+      "dependencies": {
+        "end-of-stream": "^1.1.0",
+        "once": "^1.3.1"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.14.2",
+      "resolved": "http://192.168.1.103:4873/qs/-/qs-6.14.2.tgz",
+      "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "http://192.168.1.103:4873/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "2.5.3",
+      "resolved": "http://192.168.1.103:4873/raw-body/-/raw-body-2.5.3.tgz",
+      "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/rc": {
+      "version": "1.2.8",
+      "resolved": "http://192.168.1.103:4873/rc/-/rc-1.2.8.tgz",
+      "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+      "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+      "dependencies": {
+        "deep-extend": "^0.6.0",
+        "ini": "~1.3.0",
+        "minimist": "^1.2.0",
+        "strip-json-comments": "~2.0.1"
+      },
+      "bin": {
+        "rc": "cli.js"
+      }
+    },
+    "node_modules/readable-stream": {
+      "version": "4.7.0",
+      "resolved": "http://192.168.1.103:4873/readable-stream/-/readable-stream-4.7.0.tgz",
+      "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
+      "license": "MIT",
+      "dependencies": {
+        "abort-controller": "^3.0.0",
+        "buffer": "^6.0.3",
+        "events": "^3.3.0",
+        "process": "^0.11.10",
+        "string_decoder": "^1.3.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      }
+    },
+    "node_modules/rfdc": {
+      "version": "1.4.1",
+      "resolved": "http://192.168.1.103:4873/rfdc/-/rfdc-1.4.1.tgz",
+      "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
+      "license": "MIT"
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "http://192.168.1.103:4873/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "http://192.168.1.103:4873/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/semver": {
+      "version": "7.7.4",
+      "resolved": "http://192.168.1.103:4873/semver/-/semver-7.7.4.tgz",
+      "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/send": {
+      "version": "0.19.2",
+      "resolved": "http://192.168.1.103:4873/send/-/send-0.19.2.tgz",
+      "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.1",
+        "mime": "1.6.0",
+        "ms": "2.1.3",
+        "on-finished": "~2.4.1",
+        "range-parser": "~1.2.1",
+        "statuses": "~2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/send/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "http://192.168.1.103:4873/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/serve-static": {
+      "version": "1.16.3",
+      "resolved": "http://192.168.1.103:4873/serve-static/-/serve-static-1.16.3.tgz",
+      "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "~0.19.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "http://192.168.1.103:4873/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.0",
+      "resolved": "http://192.168.1.103:4873/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "http://192.168.1.103:4873/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "http://192.168.1.103:4873/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "http://192.168.1.103:4873/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/simple-concat": {
+      "version": "1.0.1",
+      "resolved": "http://192.168.1.103:4873/simple-concat/-/simple-concat-1.0.1.tgz",
+      "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/simple-get": {
+      "version": "4.0.1",
+      "resolved": "http://192.168.1.103:4873/simple-get/-/simple-get-4.0.1.tgz",
+      "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "decompress-response": "^6.0.0",
+        "once": "^1.3.1",
+        "simple-concat": "^1.0.0"
+      }
+    },
+    "node_modules/smart-buffer": {
+      "version": "4.2.0",
+      "resolved": "http://192.168.1.103:4873/smart-buffer/-/smart-buffer-4.2.0.tgz",
+      "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 6.0.0",
+        "npm": ">= 3.0.0"
+      }
+    },
+    "node_modules/socks": {
+      "version": "2.8.8",
+      "resolved": "http://192.168.1.103:4873/socks/-/socks-2.8.8.tgz",
+      "integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==",
+      "license": "MIT",
+      "dependencies": {
+        "ip-address": "^10.1.1",
+        "smart-buffer": "^4.2.0"
+      },
+      "engines": {
+        "node": ">= 10.0.0",
+        "npm": ">= 3.0.0"
+      }
+    },
+    "node_modules/split2": {
+      "version": "4.2.0",
+      "resolved": "http://192.168.1.103:4873/split2/-/split2-4.2.0.tgz",
+      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">= 10.x"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "http://192.168.1.103:4873/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/string_decoder": {
+      "version": "1.3.0",
+      "resolved": "http://192.168.1.103:4873/string_decoder/-/string_decoder-1.3.0.tgz",
+      "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.2.0"
+      }
+    },
+    "node_modules/strip-json-comments": {
+      "version": "2.0.1",
+      "resolved": "http://192.168.1.103:4873/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+      "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/tar-fs": {
+      "version": "2.1.4",
+      "resolved": "http://192.168.1.103:4873/tar-fs/-/tar-fs-2.1.4.tgz",
+      "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "chownr": "^1.1.1",
+        "mkdirp-classic": "^0.5.2",
+        "pump": "^3.0.0",
+        "tar-stream": "^2.1.4"
+      }
+    },
+    "node_modules/tar-stream": {
+      "version": "2.2.0",
+      "resolved": "http://192.168.1.103:4873/tar-stream/-/tar-stream-2.2.0.tgz",
+      "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "bl": "^4.0.3",
+        "end-of-stream": "^1.4.1",
+        "fs-constants": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^3.1.1"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/tar-stream/node_modules/bl": {
+      "version": "4.1.0",
+      "resolved": "http://192.168.1.103:4873/bl/-/bl-4.1.0.tgz",
+      "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+      "license": "MIT",
+      "dependencies": {
+        "buffer": "^5.5.0",
+        "inherits": "^2.0.4",
+        "readable-stream": "^3.4.0"
+      }
+    },
+    "node_modules/tar-stream/node_modules/buffer": {
+      "version": "5.7.1",
+      "resolved": "http://192.168.1.103:4873/buffer/-/buffer-5.7.1.tgz",
+      "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "base64-js": "^1.3.1",
+        "ieee754": "^1.1.13"
+      }
+    },
+    "node_modules/tar-stream/node_modules/readable-stream": {
+      "version": "3.6.2",
+      "resolved": "http://192.168.1.103:4873/readable-stream/-/readable-stream-3.6.2.tgz",
+      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+      "license": "MIT",
+      "dependencies": {
+        "inherits": "^2.0.3",
+        "string_decoder": "^1.1.1",
+        "util-deprecate": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "http://192.168.1.103:4873/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "http://192.168.1.103:4873/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "license": "0BSD"
+    },
+    "node_modules/tunnel-agent": {
+      "version": "0.6.0",
+      "resolved": "http://192.168.1.103:4873/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+      "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "safe-buffer": "^5.0.1"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "http://192.168.1.103:4873/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "license": "MIT",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/typedarray": {
+      "version": "0.0.6",
+      "resolved": "http://192.168.1.103:4873/typedarray/-/typedarray-0.0.6.tgz",
+      "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+      "license": "MIT"
+    },
+    "node_modules/undici-types": {
+      "version": "7.19.2",
+      "resolved": "http://192.168.1.103:4873/undici-types/-/undici-types-7.19.2.tgz",
+      "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
+      "license": "MIT"
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "http://192.168.1.103:4873/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "http://192.168.1.103:4873/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "license": "MIT"
+    },
+    "node_modules/utils-merge": {
+      "version": "1.0.1",
+      "resolved": "http://192.168.1.103:4873/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "http://192.168.1.103:4873/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/worker-factory": {
+      "version": "7.0.49",
+      "resolved": "http://192.168.1.103:4873/worker-factory/-/worker-factory-7.0.49.tgz",
+      "integrity": "sha512-lW7tpgy6aUv2dFsQhv1yv+XFzdkCf/leoKRTGMPVK5/die6RrUjqgJHJf556qO+ZfytNG6wPXc17E8zzsOLUDw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.29.2",
+        "fast-unique-numbers": "^9.0.27",
+        "tslib": "^2.8.1"
+      }
+    },
+    "node_modules/worker-timers": {
+      "version": "8.0.31",
+      "resolved": "http://192.168.1.103:4873/worker-timers/-/worker-timers-8.0.31.tgz",
+      "integrity": "sha512-ngkq5S6JuZyztom8tDgBzorLo9byhBMko/sXfgiUD945AuzKGg1GCgDMCC3NaYkicLpGKXutONM36wEX8UbBCA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.29.2",
+        "tslib": "^2.8.1",
+        "worker-timers-broker": "^8.0.16",
+        "worker-timers-worker": "^9.0.14"
+      }
+    },
+    "node_modules/worker-timers-broker": {
+      "version": "8.0.16",
+      "resolved": "http://192.168.1.103:4873/worker-timers-broker/-/worker-timers-broker-8.0.16.tgz",
+      "integrity": "sha512-JyP3AvUGyPGbBGW7XiUewm2+0pN/aYo1QpVf5kdXAfkDZcN3p7NbWrG6XnyDEpDIvfHk/+LCnOW/NsuiU9riYA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.29.2",
+        "broker-factory": "^3.1.14",
+        "fast-unique-numbers": "^9.0.27",
+        "tslib": "^2.8.1",
+        "worker-timers-worker": "^9.0.14"
+      }
+    },
+    "node_modules/worker-timers-worker": {
+      "version": "9.0.14",
+      "resolved": "http://192.168.1.103:4873/worker-timers-worker/-/worker-timers-worker-9.0.14.tgz",
+      "integrity": "sha512-/qF06C60sXmSLfUl7WglvrDIbspmPOM8UrG63Dnn4bi2x4/DfqHS/+dxF5B+MdHnYO5tVuZYLHdAodrKdabTIg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.29.2",
+        "tslib": "^2.8.1",
+        "worker-factory": "^7.0.49"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "http://192.168.1.103:4873/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "license": "ISC"
+    },
+    "node_modules/ws": {
+      "version": "8.20.0",
+      "resolved": "http://192.168.1.103:4873/ws/-/ws-8.20.0.tgz",
+      "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.0.0"
+      },
+      "peerDependencies": {
+        "bufferutil": "^4.0.1",
+        "utf-8-validate": ">=5.0.2"
+      },
+      "peerDependenciesMeta": {
+        "bufferutil": {
+          "optional": true
+        },
+        "utf-8-validate": {
+          "optional": true
+        }
+      }
+    }
+  }
+}

+ 19 - 0
package.json

@@ -0,0 +1,19 @@
+{
+  "name": "office-light",
+  "version": "0.1.0",
+  "private": true,
+  "description": "Office light control and schedule system",
+  "main": "src/index.js",
+  "scripts": {
+    "start": "node src/index.js",
+    "dev": "node --watch src/index.js"
+  },
+  "dependencies": {
+    "better-sqlite3": "^11.8.1",
+    "dayjs": "^1.11.13",
+    "ejs": "^3.1.10",
+    "express": "^4.21.2",
+    "mqtt": "^5.10.3",
+    "node-cron": "^4.2.1"
+  }
+}

+ 430 - 0
public/styles.css

@@ -0,0 +1,430 @@
+:root {
+  --primary: #0066cc;
+  --primary-focus: #0071e3;
+  --primary-on-dark: #2997ff;
+  --canvas: #ffffff;
+  --parchment: #f5f5f7;
+  --tile-dark: #272729;
+  --tile-dark-2: #2a2a2c;
+  --ink: #1d1d1f;
+  --muted: #7a7a7a;
+  --hairline: #e0e0e0;
+}
+
+* { box-sizing: border-box; }
+body {
+  margin: 0;
+  font-family: "SF Pro Text", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+  color: var(--ink);
+  background: var(--canvas);
+}
+
+a { color: inherit; text-decoration: none; }
+button, input, select, textarea { font: inherit; }
+button:focus,
+input:focus,
+select:focus,
+textarea:focus,
+dialog:focus {
+  outline: none;
+}
+
+.global-nav {
+  position: sticky;
+  top: 0;
+  z-index: 10;
+  height: 44px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 24px;
+  background: #000;
+  color: #fff;
+  font-size: 12px;
+  letter-spacing: -0.12px;
+}
+.brand { font-weight: 600; }
+.nav-links { display: flex; gap: 8px; color: #ccc; }
+.nav-links a {
+  padding: 7px 12px;
+  border-radius: 9999px;
+  transition: background 0.12s ease, color 0.12s ease, transform 0.12s ease;
+}
+.nav-links a:hover { color: #fff; }
+.nav-links a:active { transform: scale(0.95); }
+.nav-links a.active {
+  color: #fff;
+  background: rgba(255, 255, 255, 0.22);
+  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
+}
+.mqtt-status {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  min-width: 112px;
+  justify-content: flex-end;
+  font-size: 12px;
+  color: #ff453a;
+}
+.mqtt-status.connected { color: #30d158; }
+.mqtt-status.disconnected { color: #ff453a; }
+.mqtt-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 9999px;
+  background: currentColor;
+}
+
+.tile-light, .tile-parchment, .tile-dark {
+  padding: 80px 24px;
+}
+.tile-light { background: var(--canvas); }
+.tile-parchment { background: var(--parchment); }
+.tile-dark { background: var(--tile-dark); color: #fff; }
+.compact { padding-top: 56px; padding-bottom: 56px; }
+
+.hero { text-align: center; }
+.hero h1 {
+  margin: 8px auto 12px;
+  max-width: 980px;
+  font-family: "SF Pro Display", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+  font-size: clamp(34px, 6vw, 56px);
+  line-height: 1.07;
+  letter-spacing: -0.28px;
+  font-weight: 600;
+}
+.lead {
+  max-width: 980px;
+  margin: 0 auto;
+  font-size: clamp(21px, 3vw, 28px);
+  line-height: 1.25;
+  letter-spacing: -0.2px;
+}
+.page-intro {
+  padding-top: 40px;
+  padding-bottom: 40px;
+}
+.page-intro h1 {
+  font-size: clamp(28px, 4vw, 34px);
+  line-height: 1.12;
+  margin-bottom: 8px;
+}
+.page-intro .lead {
+  font-size: 17px;
+  line-height: 1.47;
+  max-width: 720px;
+}
+.logs-intro {
+  padding-top: 32px;
+  padding-bottom: 24px;
+}
+.logs-section {
+  padding-top: 24px;
+  padding-bottom: 24px;
+}
+.eyebrow {
+  margin: 0 0 8px;
+  color: var(--muted);
+  font-size: 14px;
+}
+.tile-dark .eyebrow, .tile-dark p { color: #ccc; }
+
+.hero-actions, .inline-actions, .inline-form {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 12px;
+  margin-top: 28px;
+}
+.compact-actions { margin-top: 20px; }
+.home-status {
+  padding-bottom: 64px;
+}
+.status-note {
+  margin: 36px auto 20px;
+  color: var(--muted);
+  font-size: 17px;
+  line-height: 1.47;
+}
+.home-state-grid {
+  margin-top: 0;
+  text-align: left;
+}
+.button-primary, .button-secondary, .button-secondary-on-dark {
+  min-height: 44px;
+  border-radius: 9999px;
+  padding: 11px 22px;
+  border: 1px solid var(--primary);
+  cursor: pointer;
+  transition: transform 0.12s ease;
+}
+.button-primary {
+  background: var(--primary);
+  color: #fff;
+}
+.button-secondary {
+  background: transparent;
+  color: var(--primary);
+}
+.button-secondary-on-dark {
+  background: transparent;
+  color: var(--primary-on-dark);
+  border-color: var(--primary-on-dark);
+}
+.button-primary:active, .button-secondary:active, .button-secondary-on-dark:active { transform: scale(0.95); }
+.button-primary:focus, .button-secondary:focus, .button-secondary-on-dark:focus { outline: none; }
+.button-primary:focus-visible, .button-secondary:focus-visible, .button-secondary-on-dark:focus-visible { outline: 2px solid var(--primary-focus); outline-offset: 2px; }
+.small { min-height: 36px; padding: 8px 15px; font-size: 14px; }
+
+.section-heading {
+  max-width: 980px;
+  margin: 0 auto 32px;
+  text-align: center;
+}
+.section-heading h2 {
+  margin: 0 0 8px;
+  font-size: 40px;
+  line-height: 1.1;
+  letter-spacing: -0.28px;
+  font-weight: 600;
+}
+.section-heading p { margin: 0; font-size: 17px; line-height: 1.47; }
+.dark-text { color: var(--ink); }
+.compact-heading { margin-bottom: 24px; }
+.compact-heading .button-primary { margin-top: 20px; }
+
+.state-grid, .card-grid, .plan-columns, .two-col {
+  max-width: 1180px;
+  margin: 0 auto;
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 24px;
+}
+.plan-columns, .two-col { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+.state-card, .utility-card {
+  border-radius: 18px;
+  padding: 24px;
+  border: 1px solid var(--hairline);
+  background: #fff;
+  color: var(--ink);
+}
+.state-card { background: var(--tile-dark-2); border-color: rgba(255,255,255,0.12); color: #fff; }
+.home-state-grid .state-card {
+  background: #fff;
+  color: var(--ink);
+  border-color: var(--hairline);
+}
+.home-state-grid .state-card p,
+.home-state-grid .state-card .eyebrow {
+  color: var(--muted);
+}
+.state-value {
+  margin: 4px 0 8px;
+  font-size: 40px;
+  line-height: 1.1;
+  letter-spacing: -0.28px;
+}
+.state-on { color: #30d158; }
+.state-off { color: #ff453a; }
+.utility-card h2, .utility-card h3 { margin-top: 0; }
+.holiday-import-grid { align-items: stretch; }
+.holiday-card {
+  display: grid;
+  grid-template-rows: auto 360px auto;
+  gap: 18px;
+}
+.holiday-card > .holiday-textarea,
+.holiday-card > .holiday-form {
+  min-height: 0;
+}
+.holiday-card-header {
+  display: grid;
+  align-content: start;
+  gap: 0;
+}
+.holiday-card-header h2 { margin: 0; }
+.holiday-card-header p { margin: 0; font-size: 14px; line-height: 1.47; }
+.holiday-form {
+  display: contents;
+}
+.holiday-textarea {
+  height: 360px;
+  min-height: 360px;
+}
+.holiday-action { justify-self: center; }
+.wide { max-width: 1180px; margin-left: auto; margin-right: auto; }
+.muted { color: var(--muted); }
+
+.timeline { display: grid; gap: 14px; }
+.timeline-item {
+  display: grid;
+  gap: 4px;
+  padding-bottom: 14px;
+  border-bottom: 1px solid var(--hairline);
+}
+.timeline-item:last-child { border-bottom: 0; padding-bottom: 0; }
+.timeline-item span { color: var(--muted); }
+
+.table-wrap { overflow-x: auto; }
+.table-card-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  margin-bottom: 16px;
+}
+.table-card-header h2 { margin: 0; }
+.log-table-card { overflow: hidden; }
+.table-scroll {
+  position: relative;
+  max-height: calc(100vh - 285px);
+  overflow: auto;
+  background: var(--canvas);
+}
+table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 14px; }
+th, td { padding: 12px 10px; border-bottom: 1px solid var(--hairline); text-align: left; vertical-align: top; }
+th {
+  position: sticky;
+  top: 0;
+  z-index: 3;
+  background: var(--canvas);
+  box-shadow: inset 0 1px 0 var(--canvas), 0 1px 0 var(--hairline);
+  font-weight: 600;
+}
+
+.form-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 18px;
+  align-items: start;
+}
+label { display: grid; grid-template-rows: auto 44px auto; gap: 8px; font-size: 14px; color: var(--muted); }
+.field-hint { font-size: 12px; color: var(--muted); }
+input, select, textarea {
+  width: 100%;
+  height: 44px;
+  border: 1px solid rgba(0,0,0,0.08);
+  border-radius: 18px;
+  padding: 12px 16px;
+  background: #fff;
+  color: var(--ink);
+}
+textarea { height: auto; resize: vertical; line-height: 1.45; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
+.weekday-picker {
+  grid-column: 1 / -1;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px 18px;
+}
+.weekday-picker[hidden] { display: none; }
+.weekday-picker label { display: flex; grid-template-rows: none; align-items: center; gap: 6px; }
+.weekday-picker input { width: auto; }
+.form-actions {
+  grid-column: 1 / -1;
+  display: flex;
+  justify-content: center;
+  margin-top: 4px;
+}
+
+.notice {
+  position: fixed;
+  top: 58px;
+  left: 50%;
+  z-index: 30;
+  transform: translateX(-50%);
+  width: min(520px, calc(100vw - 32px));
+  margin: 0;
+  padding: 12px 18px;
+  border-radius: 18px;
+  background: #e8f2ff;
+  color: var(--ink);
+  text-align: center;
+  border: 1px solid rgba(0, 102, 204, 0.16);
+  animation: message-pop 3.2s ease forwards;
+}
+.notice-error {
+  background: #fff0f0;
+  border-color: rgba(255, 69, 58, 0.18);
+}
+
+@keyframes message-pop {
+  0% { opacity: 0; transform: translate(-50%, -8px); }
+  12% { opacity: 1; transform: translate(-50%, 0); }
+  78% { opacity: 1; transform: translate(-50%, 0); }
+  100% { opacity: 0; transform: translate(-50%, -8px); visibility: hidden; }
+}
+code { word-break: break-all; }
+.topic-preview p { line-height: 1.6; }
+.topic-preview { margin-top: 24px; }
+
+.modal {
+  width: min(760px, calc(100vw - 32px));
+  border: 1px solid var(--hairline);
+  border-radius: 18px;
+  padding: 24px;
+  color: var(--ink);
+  background: rgba(245, 245, 247, 0.96);
+  backdrop-filter: saturate(180%) blur(20px);
+}
+.modal::backdrop { background: rgba(0, 0, 0, 0.36); }
+.modal-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 16px;
+  margin-bottom: 24px;
+}
+.modal-header h2 {
+  margin: 0;
+  font-size: 34px;
+  line-height: 1.12;
+  letter-spacing: -0.28px;
+}
+.modal-close {
+  width: 36px;
+  height: 36px;
+  border: 0;
+  border-radius: 9999px;
+  background: rgba(0, 0, 0, 0.06);
+  color: var(--ink);
+  cursor: pointer;
+  font-size: 24px;
+  line-height: 1;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  transition: transform 0.12s ease, background 0.12s ease;
+}
+.modal-close:active { transform: scale(0.95); }
+.modal-close:hover { background: rgba(0, 0, 0, 0.1); }
+.modal-form {
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 20px 18px;
+}
+.modal-form label { min-width: 0; }
+.modal-actions {
+  grid-column: 1 / -1;
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+  margin-top: 8px;
+}
+
+@media (max-width: 833px) {
+  .global-nav { padding: 0 16px; }
+  .nav-links { gap: 6px; }
+  .state-grid, .card-grid, .plan-columns, .two-col, .form-grid { grid-template-columns: 1fr; }
+  .tile-light, .tile-parchment, .tile-dark { padding: 48px 16px; }
+  .section-heading h2 { font-size: 34px; }
+}
+
+@media (max-width: 520px) {
+  .nav-links { font-size: 11px; gap: 2px; }
+  .nav-links a { padding: 7px 8px; }
+  .mqtt-status { min-width: auto; font-size: 11px; }
+  .mqtt-status span:last-child { display: none; }
+  .brand { display: none; }
+  .hero-actions, .inline-actions { align-items: stretch; }
+  .hero-actions form, .inline-actions form, .hero-actions button, .inline-actions button { width: 100%; }
+}

+ 144 - 0
src/db.js

@@ -0,0 +1,144 @@
+const fs = require('fs');
+const path = require('path');
+const Database = require('better-sqlite3');
+
+const rootDir = path.join(__dirname, '..');
+const dataDir = path.join(rootDir, 'data');
+fs.mkdirSync(dataDir, { recursive: true });
+
+const db = new Database(path.join(dataDir, 'office-light.sqlite'));
+db.pragma('journal_mode = WAL');
+db.pragma('foreign_keys = ON');
+
+function migrate() {
+  db.exec(`
+    CREATE TABLE IF NOT EXISTS app_config (
+      key TEXT PRIMARY KEY,
+      value TEXT NOT NULL
+    );
+
+    CREATE TABLE IF NOT EXISTS light_states (
+      channel INTEGER PRIMARY KEY,
+      name TEXT NOT NULL,
+      state TEXT NOT NULL DEFAULT 'UNKNOWN',
+      last_seen_at TEXT,
+      source TEXT,
+      raw_payload TEXT
+    );
+
+    CREATE TABLE IF NOT EXISTS schedules (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      name TEXT NOT NULL,
+      target_channel INTEGER NOT NULL DEFAULT 0,
+      action TEXT NOT NULL CHECK (action IN ('open', 'close')),
+      time TEXT NOT NULL,
+      repeat_type TEXT NOT NULL CHECK (repeat_type IN ('daily', 'workday', 'holiday', 'custom')),
+      weekdays TEXT NOT NULL DEFAULT '',
+      is_enabled INTEGER NOT NULL DEFAULT 1,
+      created_at TEXT NOT NULL,
+      updated_at TEXT NOT NULL
+    );
+
+    CREATE TABLE IF NOT EXISTS schedule_runs (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      schedule_id INTEGER NOT NULL,
+      run_key TEXT NOT NULL,
+      scheduled_for TEXT NOT NULL,
+      started_at TEXT NOT NULL,
+      finished_at TEXT,
+      result TEXT NOT NULL DEFAULT 'running',
+      message TEXT,
+      UNIQUE(schedule_id, run_key),
+      FOREIGN KEY(schedule_id) REFERENCES schedules(id) ON DELETE CASCADE
+    );
+
+    CREATE TABLE IF NOT EXISTS operation_logs (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      created_at TEXT NOT NULL,
+      source TEXT NOT NULL,
+      action TEXT NOT NULL,
+      target_channel INTEGER,
+      topic TEXT,
+      payload TEXT,
+      before_state TEXT,
+      after_state TEXT,
+      result TEXT NOT NULL,
+      message TEXT,
+      raw_payload TEXT
+    );
+
+    CREATE TABLE IF NOT EXISTS holiday_calendar (
+      date TEXT PRIMARY KEY,
+      year INTEGER NOT NULL,
+      name TEXT NOT NULL,
+      type TEXT NOT NULL CHECK (type IN ('holiday', 'adjusted_workday')),
+      imported_at TEXT NOT NULL
+    );
+
+    CREATE TABLE IF NOT EXISTS mqtt_messages (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      created_at TEXT NOT NULL,
+      topic TEXT NOT NULL,
+      payload TEXT NOT NULL
+    );
+  `);
+}
+
+function seed() {
+  const defaults = {
+    mqtt_url: 'mqtt://192.168.1.109:38901',
+    topic_version: 'v2',
+    product_key: 'wss1',
+    device_id: '16F928',
+    server_port: '3000',
+    log_retention_days: '60'
+  };
+
+  const insertConfig = db.prepare('INSERT OR IGNORE INTO app_config (key, value) VALUES (?, ?)');
+  for (const [key, value] of Object.entries(defaults)) insertConfig.run(key, value);
+
+  const insertState = db.prepare(`
+    INSERT OR IGNORE INTO light_states (channel, name, state)
+    VALUES (?, ?, 'UNKNOWN')
+  `);
+  insertState.run(1, '灯1');
+  insertState.run(2, '灯2');
+  insertState.run(3, '灯3');
+}
+
+function getConfig() {
+  const rows = db.prepare('SELECT key, value FROM app_config').all();
+  return Object.fromEntries(rows.map((row) => [row.key, row.value]));
+}
+
+function updateConfig(values) {
+  const stmt = db.prepare(`
+    INSERT INTO app_config (key, value) VALUES (?, ?)
+    ON CONFLICT(key) DO UPDATE SET value = excluded.value
+  `);
+  const tx = db.transaction((entries) => {
+    for (const [key, value] of Object.entries(entries)) stmt.run(key, String(value ?? ''));
+  });
+  tx(values);
+}
+
+function getTopicConfig() {
+  const config = getConfig();
+  const version = config.topic_version || 'v2';
+  const productKey = config.product_key || 'wss1';
+  const deviceId = config.device_id || '16F928';
+  return {
+    mqttUrl: config.mqtt_url || 'mqtt://192.168.1.109:38901',
+    version,
+    productKey,
+    deviceId,
+    commandPrefix: `${version}/cmnd/${productKey}/${deviceId}`,
+    statusTopic: `${version}/stat/${productKey}/${deviceId}/RESULT`,
+    telemetryTopic: `${version}/tele/${productKey}/${deviceId}/STATE`
+  };
+}
+
+migrate();
+seed();
+
+module.exports = { db, getConfig, updateConfig, getTopicConfig };

+ 35 - 0
src/holidayPrompt.js

@@ -0,0 +1,35 @@
+function buildHolidayPrompt(year) {
+  return `请生成中国大陆 ${year} 年法定节假日和调休补班 JSON 数据。
+
+要求:
+1. 只输出 JSON,不要解释,不要 Markdown。
+2. 日期格式使用 YYYY-MM-DD。
+3. 顶层字段包含 year、holidays、adjustedWorkdays。
+4. holidays 数组包含所有放假日期,包括节假日连休日。
+5. adjustedWorkdays 数组包含所有周末调休补班日期。
+6. 每条数据包含 date、name、type。
+7. holidays 中 type 固定为 "holiday"。
+8. adjustedWorkdays 中 type 固定为 "adjusted_workday"。
+9. 适用于中国大陆办公排班判断。
+
+输出格式为 JSON,示例:
+{
+  "year": ${year},
+  "holidays": [
+    {
+      "date": "${year}-01-01",
+      "name": "元旦",
+      "type": "holiday"
+    }
+  ],
+  "adjustedWorkdays": [
+    {
+      "date": "${year}-02-14",
+      "name": "春节调休补班",
+      "type": "adjusted_workday"
+    }
+  ]
+}`;
+}
+
+module.exports = { buildHolidayPrompt };

+ 183 - 0
src/index.js

@@ -0,0 +1,183 @@
+const express = require('express');
+const path = require('path');
+const dayjs = require('dayjs');
+const { db, getConfig, getTopicConfig, updateConfig } = require('./db');
+const { buildHolidayPrompt } = require('./holidayPrompt');
+const { MqttService } = require('./mqttService');
+const { getNextOccurrence, importHolidays, listOccurrences, listSchedules } = require('./scheduleService');
+const { cleanupLogs, startScheduler } = require('./scheduler');
+
+const app = express();
+const mqttService = new MqttService();
+
+app.set('view engine', 'ejs');
+app.set('views', path.join(__dirname, '..', 'views'));
+app.use(express.urlencoded({ extended: true }));
+app.use(express.json({ limit: '1mb' }));
+app.use(express.static(path.join(__dirname, '..', 'public')));
+app.use((req, res, next) => {
+  const render = res.render.bind(res);
+  res.render = (view, locals = {}, callback) => {
+    if (typeof locals === 'function') return render(view, { currentPath: req.path }, locals);
+    return render(view, { ...locals, currentPath: req.path }, callback);
+  };
+  next();
+});
+app.locals.dayjs = dayjs;
+app.locals.getMqttStatus = () => mqttService.getStatus();
+app.locals.weekdayLabel = (value) => ['一', '二', '三', '四', '五', '六', '日'][Number(value) - 1] || '';
+app.locals.actionLabel = (action) => ({ open: '开灯', close: '关灯', query: '查询', state_change: '状态变化' }[action] || action);
+app.locals.targetLabel = (channel) => Number(channel) === 0 ? '全部灯' : `灯${channel}`;
+app.locals.repeatLabel = (type) => ({ daily: '每天', workday: '工作日', holiday: '法定节假日', custom: '自定义' }[type] || type);
+app.locals.holidayTypeLabel = (type) => ({ holiday: '节假日', adjusted_workday: '调休' }[type] || type);
+
+app.get('/', (req, res) => {
+  const states = db.prepare('SELECT * FROM light_states ORDER BY channel').all();
+  const today = dayjs().startOf('week').add(1, 'day');
+  const nextWeek = today.add(7, 'day');
+  res.render('index', {
+    title: 'Office Light',
+    config: getConfig(),
+    topicConfig: getTopicConfig(),
+    states,
+    nextOccurrence: getNextOccurrence(),
+    thisWeek: listOccurrences(today, 7),
+    nextWeek: listOccurrences(nextWeek, 7),
+    message: req.query.message,
+    error: req.query.error
+  });
+});
+
+app.post('/control', async (req, res) => {
+  try {
+    await mqttService.sendCommand({
+      channel: Number(req.body.channel || 0),
+      action: req.body.action,
+      source: 'manual',
+      retries: 1
+    });
+    res.redirect('/?message=' + encodeURIComponent('命令已执行,设备已回执。'));
+  } catch (error) {
+    res.redirect('/?error=' + encodeURIComponent(error.message));
+  }
+});
+
+app.get('/schedules', (req, res) => {
+  const today = dayjs().startOf('week').add(1, 'day');
+  const nextWeek = today.add(7, 'day');
+  res.render('schedules', {
+    title: '计划',
+    schedules: listSchedules(false),
+    nextOccurrence: getNextOccurrence(),
+    thisWeek: listOccurrences(today, 7),
+    nextWeek: listOccurrences(nextWeek, 7),
+    message: req.query.message,
+    error: req.query.error
+  });
+});
+
+app.post('/schedules', (req, res) => {
+  if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(req.body.time || '')) {
+    res.redirect('/schedules?error=' + encodeURIComponent('时间必须使用 24 小时制 HH:mm,例如 09:00 或 18:30。'));
+    return;
+  }
+
+  const weekdays = Array.isArray(req.body.weekdays) ? req.body.weekdays.join(',') : (req.body.weekdays || '');
+  const now = dayjs().toISOString();
+  db.prepare(`
+    INSERT INTO schedules (name, target_channel, action, time, repeat_type, weekdays, is_enabled, created_at, updated_at)
+    VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)
+  `).run(
+    req.body.name,
+    Number(req.body.target_channel || 0),
+    req.body.action,
+    req.body.time,
+    req.body.repeat_type,
+    weekdays,
+    now,
+    now
+  );
+  res.redirect('/schedules?message=' + encodeURIComponent('计划已创建。'));
+});
+
+app.post('/schedules/:id/toggle', (req, res) => {
+  db.prepare('UPDATE schedules SET is_enabled = CASE is_enabled WHEN 1 THEN 0 ELSE 1 END, updated_at = ? WHERE id = ?')
+    .run(dayjs().toISOString(), req.params.id);
+  res.redirect('/schedules');
+});
+
+app.post('/schedules/:id/delete', (req, res) => {
+  db.prepare('DELETE FROM schedules WHERE id = ?').run(req.params.id);
+  res.redirect('/schedules');
+});
+
+app.get('/logs', (req, res) => {
+  const logs = db.prepare('SELECT * FROM operation_logs ORDER BY created_at DESC LIMIT 200').all();
+  res.render('logs', { title: '记录', logs });
+});
+
+app.get('/holidays', (req, res) => {
+  const promptYear = dayjs().add(1, 'year').year();
+  const rows = db.prepare('SELECT * FROM holiday_calendar ORDER BY date ASC').all();
+  res.render('holidays', {
+    title: '节假日',
+    promptYear,
+    prompt: buildHolidayPrompt(promptYear),
+    rows,
+    message: req.query.message,
+    error: req.query.error
+  });
+});
+
+app.post('/holidays/import', (req, res) => {
+  try {
+    const input = JSON.parse(req.body.holiday_json || '{}');
+    const count = importHolidays(input);
+    res.redirect(`/holidays?message=${encodeURIComponent(`已导入 ${count} 条节假日数据。`)}`);
+  } catch (error) {
+    res.redirect('/holidays?error=' + encodeURIComponent(error.message));
+  }
+});
+
+app.post('/holidays/clear', (req, res) => {
+  db.prepare('DELETE FROM holiday_calendar').run();
+  res.redirect('/holidays?message=' + encodeURIComponent('已清空节假日导入数据。'));
+});
+
+app.get('/settings', (req, res) => {
+  res.render('settings', {
+    title: '设置',
+    config: getConfig(),
+    topicConfig: getTopicConfig(),
+    states: db.prepare('SELECT * FROM light_states ORDER BY channel').all(),
+    message: req.query.message,
+    error: req.query.error
+  });
+});
+
+app.post('/settings', (req, res) => {
+  updateConfig({
+    mqtt_url: req.body.mqtt_url,
+    topic_version: req.body.topic_version,
+    product_key: req.body.product_key,
+    device_id: req.body.device_id,
+    server_port: req.body.server_port || '3000',
+    log_retention_days: req.body.log_retention_days || '60'
+  });
+  const updateName = db.prepare('UPDATE light_states SET name = ? WHERE channel = ?');
+  updateName.run(req.body.channel_1_name || '灯1', 1);
+  updateName.run(req.body.channel_2_name || '灯2', 2);
+  updateName.run(req.body.channel_3_name || '灯3', 3);
+  mqttService.reconnect();
+  res.redirect('/settings?message=' + encodeURIComponent('设置已保存,MQTT 已重新连接。'));
+});
+
+cleanupLogs();
+mqttService.connect();
+startScheduler(mqttService);
+
+const config = getConfig();
+const port = Number(process.env.PORT || config.server_port || 3000);
+app.listen(port, () => {
+  console.log(`Office Light running at http://localhost:${port}`);
+});

+ 248 - 0
src/mqttService.js

@@ -0,0 +1,248 @@
+const dayjs = require('dayjs');
+const mqtt = require('mqtt');
+const { db, getTopicConfig } = require('./db');
+
+const ACTION_PAYLOAD = {
+  open: '1',
+  close: '0',
+  query: ''
+};
+
+const ACTION_STATE = {
+  open: 'ON',
+  close: 'OFF'
+};
+
+class MqttService {
+  constructor() {
+    this.client = null;
+    this.status = {
+      connected: false,
+      message: '未连接',
+      lastError: null,
+      connectedAt: null
+    };
+    this.pending = new Map();
+  }
+
+  connect() {
+    const topicConfig = getTopicConfig();
+    if (this.client) this.client.end(true);
+
+    this.status = { connected: false, message: '连接中', lastError: null, connectedAt: null };
+    this.client = mqtt.connect(topicConfig.mqttUrl, { reconnectPeriod: 5000 });
+
+    this.client.on('connect', () => {
+      const latestTopicConfig = getTopicConfig();
+      this.status = {
+        connected: true,
+        message: '已连接',
+        lastError: null,
+        connectedAt: dayjs().format('YYYY-MM-DD HH:mm:ss')
+      };
+      this.client.subscribe([latestTopicConfig.statusTopic, latestTopicConfig.telemetryTopic]);
+    });
+
+    this.client.on('reconnect', () => {
+      this.status.message = '重连中';
+      this.status.connected = false;
+    });
+
+    this.client.on('close', () => {
+      this.status.connected = false;
+      this.status.message = '连接已断开';
+    });
+
+    this.client.on('error', (error) => {
+      this.status.connected = false;
+      this.status.message = '连接错误';
+      this.status.lastError = error.message;
+    });
+
+    this.client.on('message', (topic, payloadBuffer) => {
+      this.handleMessage(topic, payloadBuffer.toString());
+    });
+  }
+
+  reconnect() {
+    this.connect();
+  }
+
+  getStatus() {
+    return this.status;
+  }
+
+  commandTopic(channel) {
+    const topicConfig = getTopicConfig();
+    return `${topicConfig.commandPrefix}/Power${channel}`;
+  }
+
+  async sendCommand({ channel = 0, action, source = 'manual', retries = 1 }) {
+    if (!['open', 'close', 'query'].includes(action)) throw new Error('不支持的动作。');
+    if (!this.client || !this.status.connected) throw new Error('MQTT 未连接。');
+
+    const topic = this.commandTopic(channel);
+    const payload = ACTION_PAYLOAD[action];
+    const beforeState = JSON.stringify(this.getStateSnapshot(channel));
+    const startedAt = dayjs().toISOString();
+    const result = await this.publishAndWait({ topic, payload, channel, action, retries });
+    const afterState = JSON.stringify(this.getStateSnapshot(channel));
+
+    db.prepare(`
+      INSERT INTO operation_logs
+        (created_at, source, action, target_channel, topic, payload, before_state, after_state, result, message)
+      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+    `).run(
+      startedAt,
+      source,
+      action,
+      channel,
+      topic,
+      payload,
+      beforeState,
+      afterState,
+      result.ok ? 'success' : 'timeout',
+      result.message
+    );
+
+    if (!result.ok) throw new Error(result.message);
+    return result;
+  }
+
+  publishAndWait({ topic, payload, channel, action, retries }) {
+    return new Promise((resolve) => {
+      const id = `${Date.now()}-${Math.random()}`;
+      let attempts = 0;
+      let timeout = null;
+
+      const publishAttempt = () => {
+        attempts += 1;
+        this.client.publish(topic, payload, { qos: 0 }, (error) => {
+          if (error) {
+            cleanup();
+            resolve({ ok: false, message: error.message });
+          }
+        });
+
+        timeout = setTimeout(() => {
+          if (attempts <= retries) {
+            publishAttempt();
+            return;
+          }
+          cleanup();
+          resolve({ ok: false, message: '等待设备回执超时。' });
+        }, 5000);
+      };
+
+      const cleanup = () => {
+        clearTimeout(timeout);
+        this.pending.delete(id);
+      };
+
+      this.pending.set(id, {
+        channel,
+        action,
+        resolve: (message) => {
+          cleanup();
+          resolve({ ok: true, message });
+        }
+      });
+
+      publishAttempt();
+    });
+  }
+
+  getStateSnapshot(channel) {
+    if (channel === 0) {
+      return db.prepare('SELECT channel, name, state FROM light_states ORDER BY channel').all();
+    }
+    return db.prepare('SELECT channel, name, state FROM light_states WHERE channel = ?').get(channel);
+  }
+
+  handleMessage(topic, payload) {
+    const now = dayjs().toISOString();
+    db.prepare('INSERT INTO mqtt_messages (created_at, topic, payload) VALUES (?, ?, ?)').run(now, topic, payload);
+
+    let parsed;
+    try {
+      parsed = JSON.parse(payload);
+    } catch {
+      return;
+    }
+
+    const topicConfig = getTopicConfig();
+    const source = topic === topicConfig.telemetryTopic ? 'telemetry' : 'result';
+    const changes = this.updateStatesFromPayload(parsed, source, payload);
+    if (source === 'telemetry') this.logTelemetryChanges(changes, topic, payload);
+    this.resolvePending(parsed, source, payload);
+  }
+
+  updateStatesFromPayload(parsed, source, rawPayload) {
+    const now = dayjs().toISOString();
+    const changes = [];
+    const update = db.prepare(`
+      UPDATE light_states
+      SET state = ?, last_seen_at = ?, source = ?, raw_payload = ?
+      WHERE channel = ?
+    `);
+    const get = db.prepare('SELECT state FROM light_states WHERE channel = ?');
+
+    for (let channel = 1; channel <= 3; channel += 1) {
+      const value = parsed[`POWER${channel}`] ?? parsed[`Power${channel}`];
+      if (!['ON', 'OFF'].includes(value)) continue;
+      const previous = get.get(channel)?.state || 'UNKNOWN';
+      update.run(value, now, source, rawPayload, channel);
+      if (previous !== value) changes.push({ channel, previous, next: value });
+    }
+    return changes;
+  }
+
+  logTelemetryChanges(changes, topic, payload) {
+    const insert = db.prepare(`
+      INSERT INTO operation_logs
+        (created_at, source, action, target_channel, topic, payload, before_state, after_state, result, message, raw_payload)
+      VALUES (?, 'telemetry', 'state_change', ?, ?, '', ?, ?, 'success', ?, ?)
+    `);
+    const now = dayjs().toISOString();
+    for (const change of changes) {
+      insert.run(
+        now,
+        change.channel,
+        topic,
+        change.previous,
+        change.next,
+        `设备上报:${change.previous} -> ${change.next}`,
+        payload
+      );
+    }
+  }
+
+  resolvePending(parsed, source, payload) {
+    for (const pending of this.pending.values()) {
+      if (source === 'telemetry' && pending.action !== 'query') continue;
+      if (this.matchesPending(parsed, pending)) {
+        const prefix = source === 'telemetry' ? '已收到设备状态上报' : '设备已回执';
+        pending.resolve(`${prefix}:${payload}`);
+      }
+    }
+  }
+
+  matchesPending(parsed, pending) {
+    if (pending.action === 'query') {
+      if (pending.channel === 0) return [1, 2, 3].some((channel) => this.getPowerValue(parsed, channel));
+      return Boolean(this.getPowerValue(parsed, pending.channel));
+    }
+
+    const expected = ACTION_STATE[pending.action];
+    if (pending.channel === 0) {
+      return [1, 2, 3].every((channel) => this.getPowerValue(parsed, channel) === expected);
+    }
+    return this.getPowerValue(parsed, pending.channel) === expected;
+  }
+
+  getPowerValue(parsed, channel) {
+    return parsed[`POWER${channel}`] ?? parsed[`Power${channel}`];
+  }
+}
+
+module.exports = { MqttService };

+ 135 - 0
src/scheduleService.js

@@ -0,0 +1,135 @@
+const dayjs = require('dayjs');
+const { db } = require('./db');
+
+function toChinaWeekday(date) {
+  const day = dayjs(date).day();
+  return day === 0 ? 7 : day;
+}
+
+function getHolidayType(dateText) {
+  const row = db.prepare('SELECT type FROM holiday_calendar WHERE date = ?').get(dateText);
+  return row ? row.type : null;
+}
+
+function isWorkday(dateText) {
+  const type = getHolidayType(dateText);
+  if (type === 'adjusted_workday') return true;
+  if (type === 'holiday') return false;
+  const weekday = toChinaWeekday(dateText);
+  return weekday >= 1 && weekday <= 5;
+}
+
+function isHoliday(dateText) {
+  return getHolidayType(dateText) === 'holiday';
+}
+
+function scheduleMatchesDate(schedule, dateText) {
+  if (schedule.repeat_type === 'daily') return true;
+  if (schedule.repeat_type === 'workday') return isWorkday(dateText);
+  if (schedule.repeat_type === 'holiday') return isHoliday(dateText);
+  if (schedule.repeat_type === 'custom') {
+    const weekdays = String(schedule.weekdays || '')
+      .split(',')
+      .filter(Boolean)
+      .map(Number);
+    return weekdays.includes(toChinaWeekday(dateText));
+  }
+  return false;
+}
+
+function listSchedules(onlyEnabled = false) {
+  const sql = onlyEnabled
+    ? 'SELECT * FROM schedules WHERE is_enabled = 1 ORDER BY time ASC, id ASC'
+    : 'SELECT * FROM schedules ORDER BY is_enabled DESC, time ASC, id ASC';
+  return db.prepare(sql).all();
+}
+
+function listOccurrences(startDate, days) {
+  const schedules = listSchedules(true);
+  const occurrences = [];
+  for (let offset = 0; offset < days; offset += 1) {
+    const date = dayjs(startDate).add(offset, 'day');
+    const dateText = date.format('YYYY-MM-DD');
+    for (const schedule of schedules) {
+      if (!scheduleMatchesDate(schedule, dateText)) continue;
+      occurrences.push({
+        ...schedule,
+        date: dateText,
+        at: `${dateText} ${schedule.time}`,
+        weekday: toChinaWeekday(dateText)
+      });
+    }
+  }
+  return occurrences.sort((a, b) => a.at.localeCompare(b.at));
+}
+
+function getNextOccurrence(now = dayjs()) {
+  const candidates = listOccurrences(now.format('YYYY-MM-DD'), 30)
+    .filter((item) => dayjs(item.at).isAfter(now));
+  return candidates[0] || null;
+}
+
+function getDueSchedules(now = dayjs()) {
+  const dateText = now.format('YYYY-MM-DD');
+  const timeText = now.format('HH:mm');
+  return listSchedules(true).filter((schedule) => {
+    return schedule.time === timeText && scheduleMatchesDate(schedule, dateText);
+  });
+}
+
+function validateHolidayImport(input) {
+  if (!input || typeof input !== 'object') throw new Error('JSON 顶层必须是对象。');
+  if (!Number.isInteger(input.year)) throw new Error('year 必须是数字。');
+  if (!Array.isArray(input.holidays)) throw new Error('holidays 必须是数组。');
+  if (!Array.isArray(input.adjustedWorkdays)) throw new Error('adjustedWorkdays 必须是数组。');
+
+  const datePattern = /^\d{4}-\d{2}-\d{2}$/;
+  const seen = new Set();
+  const rows = [];
+
+  for (const item of input.holidays) {
+    if (!datePattern.test(item.date || '')) throw new Error(`无效日期:${item.date}`);
+    if (item.type !== 'holiday') throw new Error(`${item.date} 的 type 必须是 holiday。`);
+    if (seen.has(item.date)) throw new Error(`重复日期:${item.date}`);
+    seen.add(item.date);
+    rows.push({ date: item.date, year: input.year, name: item.name || '法定节假日', type: 'holiday' });
+  }
+
+  for (const item of input.adjustedWorkdays) {
+    if (!datePattern.test(item.date || '')) throw new Error(`无效日期:${item.date}`);
+    if (item.type !== 'adjusted_workday') throw new Error(`${item.date} 的 type 必须是 adjusted_workday。`);
+    if (seen.has(item.date)) throw new Error(`重复日期:${item.date}`);
+    seen.add(item.date);
+    rows.push({ date: item.date, year: input.year, name: item.name || '调休补班', type: 'adjusted_workday' });
+  }
+
+  return rows;
+}
+
+function importHolidays(input) {
+  const rows = validateHolidayImport(input);
+  const now = dayjs().toISOString();
+  const tx = db.transaction(() => {
+    db.prepare('DELETE FROM holiday_calendar WHERE year = ?').run(input.year);
+    const insert = db.prepare(`
+      INSERT INTO holiday_calendar (date, year, name, type, imported_at)
+      VALUES (?, ?, ?, ?, ?)
+    `);
+    for (const row of rows) insert.run(row.date, row.year, row.name, row.type, now);
+  });
+  tx();
+  return rows.length;
+}
+
+module.exports = {
+  getDueSchedules,
+  getNextOccurrence,
+  importHolidays,
+  isHoliday,
+  isWorkday,
+  listOccurrences,
+  listSchedules,
+  scheduleMatchesDate,
+  toChinaWeekday,
+  validateHolidayImport
+};

+ 64 - 0
src/scheduler.js

@@ -0,0 +1,64 @@
+const cron = require('node-cron');
+const dayjs = require('dayjs');
+const { db, getConfig } = require('./db');
+const { getDueSchedules } = require('./scheduleService');
+
+function startScheduler(mqttService) {
+  cron.schedule('* * * * *', async () => {
+    await runDueSchedules(mqttService);
+    cleanupLogs();
+  });
+}
+
+async function runDueSchedules(mqttService) {
+  const now = dayjs();
+  const schedules = getDueSchedules(now);
+  for (const schedule of schedules) {
+    const runKey = now.format('YYYY-MM-DD HH:mm');
+    const inserted = createScheduleRun(schedule.id, runKey, now);
+    if (!inserted) continue;
+
+    try {
+      const result = await mqttService.sendCommand({
+        channel: schedule.target_channel,
+        action: schedule.action,
+        source: 'schedule',
+        retries: 1
+      });
+      finishScheduleRun(schedule.id, runKey, 'success', result.message);
+    } catch (error) {
+      finishScheduleRun(schedule.id, runKey, 'failed', error.message);
+    }
+  }
+}
+
+function createScheduleRun(scheduleId, runKey, now) {
+  try {
+    db.prepare(`
+      INSERT INTO schedule_runs (schedule_id, run_key, scheduled_for, started_at)
+      VALUES (?, ?, ?, ?)
+    `).run(scheduleId, runKey, now.toISOString(), dayjs().toISOString());
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+function finishScheduleRun(scheduleId, runKey, result, message) {
+  db.prepare(`
+    UPDATE schedule_runs
+    SET finished_at = ?, result = ?, message = ?
+    WHERE schedule_id = ? AND run_key = ?
+  `).run(dayjs().toISOString(), result, message, scheduleId, runKey);
+}
+
+function cleanupLogs() {
+  const config = getConfig();
+  const days = Number(config.log_retention_days || 60);
+  const before = dayjs().subtract(days, 'day').toISOString();
+  db.prepare('DELETE FROM operation_logs WHERE created_at < ?').run(before);
+  db.prepare('DELETE FROM mqtt_messages WHERE created_at < ?').run(before);
+  db.prepare('DELETE FROM schedule_runs WHERE started_at < ?').run(before);
+}
+
+module.exports = { cleanupLogs, runDueSchedules, startScheduler };

+ 72 - 0
views/holidays.ejs

@@ -0,0 +1,72 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title><%= title %></title>
+  <link rel="stylesheet" href="/styles.css">
+</head>
+<body>
+  <%- include('partials/nav') %>
+  <main>
+    <section class="hero tile-light compact page-intro">
+      <p class="eyebrow">Mainland China Calendar</p>
+      <h1>节假日导入</h1>
+      <p class="lead">复制提示词给大模型,生成 JSON 后粘贴导入。</p>
+    </section>
+    <% if (message) { %><div class="notice"><%= message %></div><% } %>
+    <% if (error) { %><div class="notice notice-error"><%= error %></div><% } %>
+
+    <section class="tile-parchment compact two-col holiday-import-grid">
+      <article class="utility-card holiday-card">
+        <div class="holiday-card-header">
+          <h2>生成节假日数据</h2>
+        </div>
+        <textarea class="holiday-textarea" id="prompt" readonly><%= prompt %></textarea>
+        <button class="button-primary holiday-action" type="button" onclick="copyPrompt()">复制提示词</button>
+      </article>
+      <article class="utility-card holiday-card">
+        <div class="holiday-card-header">
+          <h2>粘贴并导入 JSON</h2>
+        </div>
+        <form method="post" action="/holidays/import" class="holiday-form">
+          <textarea class="holiday-textarea" name="holiday_json" placeholder="把大模型生成的 JSON 粘贴到这里"></textarea>
+          <button class="button-primary holiday-action">导入节假日</button>
+        </form>
+      </article>
+    </section>
+
+    <section class="tile-light compact">
+      <div class="table-wrap utility-card wide">
+        <div class="table-card-header">
+          <h2>已导入数据</h2>
+          <% if (rows.length) { %>
+            <form method="post" action="/holidays/clear" onsubmit="return confirm('确定清空所有已导入的节假日数据吗?')">
+              <button class="button-secondary small">清空数据</button>
+            </form>
+          <% } %>
+        </div>
+        <table>
+          <thead><tr><th>年份</th><th>日期</th><th>名称</th><th>类型</th></tr></thead>
+          <tbody>
+            <% rows.forEach((row) => { %>
+              <tr><td><%= row.year %></td><td><%= row.date %></td><td><%= row.name %></td><td><%= holidayTypeLabel(row.type) %></td></tr>
+            <% }) %>
+          </tbody>
+        </table>
+      </div>
+    </section>
+  </main>
+  <script>
+    async function copyPrompt() {
+      const prompt = document.getElementById('prompt');
+      try {
+        await navigator.clipboard.writeText(prompt.value);
+      } catch {
+        prompt.select();
+        document.execCommand('copy');
+      }
+    }
+  </script>
+</body>
+</html>

+ 45 - 0
views/index.ejs

@@ -0,0 +1,45 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title><%= title %></title>
+  <link rel="stylesheet" href="/styles.css">
+</head>
+<body>
+  <%- include('partials/nav') %>
+  <main>
+    <% if (message) { %><div class="notice"><%= message %></div><% } %>
+    <% if (error) { %><div class="notice notice-error"><%= error %></div><% } %>
+
+    <section class="hero tile-light home-status">
+      <p class="eyebrow"><%= topicConfig.productKey %> · <%= topicConfig.deviceId %></p>
+      <h1>办公室灯光</h1>
+      <p class="lead">
+        <%= states.filter((item) => item.state === 'ON').length %> 组开启,<%= states.filter((item) => item.state === 'OFF').length %> 组关闭。
+        <% if (nextOccurrence) { %>下一次:<%= actionLabel(nextOccurrence.action) %>,<%= nextOccurrence.at %>。<% } else { %>暂无后续计划。<% } %>
+      </p>
+      <div class="hero-actions">
+        <form method="post" action="/control"><input type="hidden" name="channel" value="0"><button class="button-primary" name="action" value="open">全部打开</button></form>
+        <form method="post" action="/control"><input type="hidden" name="channel" value="0"><button class="button-secondary" name="action" value="close">全部关闭</button></form>
+        <form method="post" action="/control"><input type="hidden" name="channel" value="0"><button class="button-secondary" name="action" value="query">查询状态</button></form>
+      </div>
+      <p class="status-note">设备每 5 分钟主动上报,也可以手动查询。</p>
+      <div class="state-grid home-state-grid">
+        <% states.forEach((state) => { %>
+          <article class="state-card">
+            <p class="eyebrow"><%= state.name %></p>
+            <h3 class="state-value state-<%= state.state.toLowerCase() %>"><%= state.state %></h3>
+            <p>最后更新:<%= state.last_seen_at ? dayjs(state.last_seen_at).format('YYYY-MM-DD HH:mm:ss') : '暂无' %></p>
+            <div class="inline-actions">
+              <form method="post" action="/control"><input type="hidden" name="channel" value="<%= state.channel %>"><button class="button-primary small" name="action" value="open">打开</button></form>
+              <form method="post" action="/control"><input type="hidden" name="channel" value="<%= state.channel %>"><button class="button-secondary small" name="action" value="close">关闭</button></form>
+              <form method="post" action="/control"><input type="hidden" name="channel" value="<%= state.channel %>"><button class="button-secondary small" name="action" value="query">查询</button></form>
+            </div>
+          </article>
+        <% }) %>
+      </div>
+    </section>
+  </main>
+</body>
+</html>

+ 41 - 0
views/logs.ejs

@@ -0,0 +1,41 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title><%= title %></title>
+  <link rel="stylesheet" href="/styles.css">
+</head>
+<body>
+  <%- include('partials/nav') %>
+  <main>
+    <section class="hero tile-light compact page-intro logs-intro">
+      <p class="eyebrow">History</p>
+      <h1>开关记录</h1>
+      <p class="lead">显示最近 200 条记录,系统自动保留 60 天。</p>
+    </section>
+    <section class="tile-parchment compact logs-section">
+      <div class="utility-card wide log-table-card">
+        <div class="table-wrap table-scroll">
+          <table>
+            <thead><tr><th>时间</th><th>来源</th><th>动作</th><th>目标</th><th>结果</th><th>说明</th><th>Topic</th></tr></thead>
+            <tbody>
+              <% logs.forEach((log) => { %>
+                <tr>
+                  <td><%= dayjs(log.created_at).format('YYYY-MM-DD HH:mm:ss') %></td>
+                  <td><%= log.source %></td>
+                  <td><%= actionLabel(log.action) %></td>
+                  <td><%= targetLabel(log.target_channel) %></td>
+                  <td><%= log.result %></td>
+                  <td><%= log.message || '' %></td>
+                  <td><%= log.topic || '' %></td>
+                </tr>
+              <% }) %>
+            </tbody>
+          </table>
+        </div>
+      </div>
+    </section>
+  </main>
+</body>
+</html>

+ 16 - 0
views/partials/nav.ejs

@@ -0,0 +1,16 @@
+<nav class="global-nav">
+  <a href="/" class="brand">Office Light</a>
+  <% const navPath = typeof currentPath !== 'undefined' ? currentPath : '/'; %>
+  <div class="nav-links">
+    <a class="<%= navPath === '/' ? 'active' : '' %>" href="/">首页</a>
+    <a class="<%= navPath.startsWith('/schedules') ? 'active' : '' %>" href="/schedules">计划</a>
+    <a class="<%= navPath.startsWith('/logs') ? 'active' : '' %>" href="/logs">记录</a>
+    <a class="<%= navPath.startsWith('/holidays') ? 'active' : '' %>" href="/holidays">节假日</a>
+    <a class="<%= navPath.startsWith('/settings') ? 'active' : '' %>" href="/settings">设置</a>
+  </div>
+  <% const navMqttStatus = typeof getMqttStatus === 'function' ? getMqttStatus() : { connected: false, message: '未连接' }; %>
+  <div class="mqtt-status <%= navMqttStatus.connected ? 'connected' : 'disconnected' %>" title="<%= navMqttStatus.lastError || '' %>">
+    <span class="mqtt-dot"></span>
+    <span>MQTT <%= navMqttStatus.connected ? '已连接' : (navMqttStatus.message || '未连接') %></span>
+  </div>
+</nav>

+ 14 - 0
views/partials/occurrences.ejs

@@ -0,0 +1,14 @@
+<article class="utility-card">
+  <h3><%= title %></h3>
+  <% if (!items.length) { %>
+    <p class="muted">暂无计划。</p>
+  <% } %>
+  <div class="timeline">
+    <% items.forEach((item) => { %>
+      <div class="timeline-item">
+        <strong><%= item.date %> 周<%= weekdayLabel(item.weekday) %> <%= item.time %></strong>
+        <span><%= actionLabel(item.action) %> · <%= targetLabel(item.target_channel) %> · <%= item.name %></span>
+      </div>
+    <% }) %>
+  </div>
+</article>

+ 136 - 0
views/schedules.ejs

@@ -0,0 +1,136 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title><%= title %></title>
+  <link rel="stylesheet" href="/styles.css">
+</head>
+<body>
+  <%- include('partials/nav') %>
+  <%
+    const scheduleNextOccurrence = typeof nextOccurrence !== 'undefined' ? nextOccurrence : null;
+    const scheduleThisWeek = typeof thisWeek !== 'undefined' && Array.isArray(thisWeek) ? thisWeek : [];
+    const scheduleNextWeek = typeof nextWeek !== 'undefined' && Array.isArray(nextWeek) ? nextWeek : [];
+  %>
+  <main>
+    <section class="hero tile-light compact page-intro">
+      <p class="eyebrow">Schedule</p>
+      <h1>定时计划</h1>
+      <p class="lead">支持每天、工作日、法定节假日、自定义。</p>
+      <div class="hero-actions compact-actions">
+        <button class="button-primary" type="button" id="openScheduleDialog">添加计划</button>
+      </div>
+    </section>
+    <% if (message) { %><div class="notice"><%= message %></div><% } %>
+    <% if (error) { %><div class="notice notice-error"><%= error %></div><% } %>
+
+    <section class="tile-parchment compact">
+      <div class="section-heading dark-text">
+        <h2>本周与下周计划</h2>
+        <p>
+          <% if (scheduleNextOccurrence) { %>
+            下一次:<%= actionLabel(scheduleNextOccurrence.action) %>,<%= scheduleNextOccurrence.at %>。
+          <% } else { %>
+            暂无后续计划。
+          <% } %>
+        </p>
+      </div>
+      <div class="plan-columns">
+        <%- include('partials/occurrences', { title: '本周计划', items: scheduleThisWeek }) %>
+        <%- include('partials/occurrences', { title: '下周计划', items: scheduleNextWeek }) %>
+      </div>
+    </section>
+
+    <section class="tile-light compact">
+      <div class="card-grid">
+        <% schedules.forEach((item) => { %>
+          <article class="utility-card">
+            <p class="eyebrow"><%= item.is_enabled ? '启用' : '停用' %></p>
+            <h3><%= item.name %></h3>
+            <p><%= item.time %> · <%= actionLabel(item.action) %> · <%= targetLabel(item.target_channel) %></p>
+            <p><%= repeatLabel(item.repeat_type) %><% if (item.weekdays) { %>:<%= item.weekdays.split(',').map(weekdayLabel).join('、') %><% } %></p>
+            <div class="inline-actions">
+              <form method="post" action="/schedules/<%= item.id %>/toggle"><button class="button-secondary small"><%= item.is_enabled ? '停用' : '启用' %></button></form>
+              <form method="post" action="/schedules/<%= item.id %>/delete"><button class="button-secondary small">删除</button></form>
+            </div>
+          </article>
+        <% }) %>
+      </div>
+    </section>
+  </main>
+
+  <dialog class="modal" id="scheduleDialog">
+    <div class="modal-header">
+      <div>
+        <p class="eyebrow">Schedule</p>
+        <h2>添加计划</h2>
+      </div>
+      <button class="modal-close" type="button" id="closeScheduleDialog" aria-label="关闭">×</button>
+    </div>
+    <form method="post" action="/schedules" class="form-grid modal-form">
+      <label>名称<input name="name" required placeholder="例如 工作日开灯"></label>
+      <label>目标
+        <select name="target_channel">
+          <option value="0">全部灯</option>
+          <option value="1">灯1</option>
+          <option value="2">灯2</option>
+          <option value="3">灯3</option>
+        </select>
+      </label>
+      <label>动作
+        <select name="action">
+          <option value="open">开灯</option>
+          <option value="close">关灯</option>
+        </select>
+      </label>
+      <label>时间<input name="time" type="text" inputmode="numeric" pattern="([01][0-9]|2[0-3]):[0-5][0-9]" placeholder="24 小时制 HH:mm,例如 09:00、18:30" required></label>
+      <label>重复
+        <select name="repeat_type" id="repeatType">
+          <option value="daily">每天</option>
+          <option value="workday">工作日</option>
+          <option value="holiday">法定节假日</option>
+          <option value="custom">自定义</option>
+        </select>
+      </label>
+      <div class="weekday-picker" id="weekdayPicker" hidden>
+        <% [1,2,3,4,5,6,7].forEach((day) => { %>
+          <label><input type="checkbox" name="weekdays" value="<%= day %>"> 周<%= weekdayLabel(day) %></label>
+        <% }) %>
+      </div>
+      <div class="modal-actions">
+        <button class="button-secondary" type="button" id="cancelScheduleDialog">取消</button>
+        <button class="button-primary">创建计划</button>
+      </div>
+    </form>
+  </dialog>
+
+  <script>
+    const scheduleDialog = document.getElementById('scheduleDialog');
+    const openScheduleDialog = document.getElementById('openScheduleDialog');
+    const closeScheduleDialog = document.getElementById('closeScheduleDialog');
+    const cancelScheduleDialog = document.getElementById('cancelScheduleDialog');
+    const repeatType = document.getElementById('repeatType');
+    const weekdayPicker = document.getElementById('weekdayPicker');
+
+    function syncWeekdayPicker() {
+      const isCustom = repeatType.value === 'custom';
+      weekdayPicker.hidden = !isCustom;
+      if (!isCustom) {
+        weekdayPicker.querySelectorAll('input[type="checkbox"]').forEach((input) => {
+          input.checked = false;
+        });
+      }
+    }
+
+    openScheduleDialog.addEventListener('click', () => scheduleDialog.showModal());
+    closeScheduleDialog.addEventListener('click', () => scheduleDialog.close());
+    cancelScheduleDialog.addEventListener('click', () => scheduleDialog.close());
+    scheduleDialog.addEventListener('click', (event) => {
+      if (event.target === scheduleDialog) scheduleDialog.close();
+    });
+    repeatType.addEventListener('change', syncWeekdayPicker);
+    syncWeekdayPicker();
+  </script>
+</body>
+</html>

+ 45 - 0
views/settings.ejs

@@ -0,0 +1,45 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title><%= title %></title>
+  <link rel="stylesheet" href="/styles.css">
+</head>
+<body>
+  <%- include('partials/nav') %>
+  <main>
+    <section class="hero tile-light compact page-intro">
+      <p class="eyebrow">Settings</p>
+      <h1>系统设置</h1>
+      <p class="lead">设备 ID 可单独配置,Topic 会自动生成。</p>
+    </section>
+    <% if (message) { %><div class="notice"><%= message %></div><% } %>
+    <% if (error) { %><div class="notice notice-error"><%= error %></div><% } %>
+    <section class="tile-parchment compact">
+      <article class="utility-card wide">
+        <form method="post" action="/settings" class="form-grid">
+          <label>MQTT 地址<input name="mqtt_url" value="<%= config.mqtt_url %>" required></label>
+          <label>Topic 版本<input name="topic_version" value="<%= config.topic_version %>" required></label>
+          <label>产品标识<input name="product_key" value="<%= config.product_key %>" required></label>
+          <label>设备 ID<input name="device_id" value="<%= config.device_id %>" required></label>
+          <label>Web 端口<input name="server_port" type="number" value="<%= config.server_port %>" required></label>
+          <label>日志保留天数<input name="log_retention_days" type="number" min="1" value="<%= config.log_retention_days %>" required></label>
+          <% states.forEach((state) => { %>
+            <label>灯<%= state.channel %> 名称<input name="channel_<%= state.channel %>_name" value="<%= state.name %>" required></label>
+          <% }) %>
+          <div class="form-actions">
+            <button class="button-primary">保存设置</button>
+          </div>
+        </form>
+      </article>
+      <article class="utility-card wide topic-preview">
+        <h2>当前 Topic</h2>
+        <p>查询/控制:<code><%= topicConfig.commandPrefix %>/Power0</code></p>
+        <p>状态回执:<code><%= topicConfig.statusTopic %></code></p>
+        <p>设备上报:<code><%= topicConfig.telemetryTopic %></code></p>
+      </article>
+    </section>
+  </main>
+</body>
+</html>