diff --git a/README.md b/README.md
index 79881fd..adcc445 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,10 @@
运行在自有小电脑上的视频管理程序。Python 标准库负责对象、空间、配置与登录;MediaMTX 负责 RTSP 接入、HLS 实时预览、本机录像及回放;浏览器使用本地打包的 hls.js。
+## 界面风格
+
+v0.1.22 采用「庭院书卷」视觉:宣纸白底、黛青导航、朱砂点缀与淡金本人标记。统一实时监控、家谱树、家族记事、设备档案、表单和登录界面;全屏监控保留深色背景。样式集中在 `web/chinese-theme.css`,本地系统宋体用于标题,不请求外部字体。桌面和手机使用同一套功能与自适应布局。
+
## 对象与空间
- **空间档案**:家庭 / 公司 / 其他,所属家庭或单位,省、市、区县、街道、小区或园区、楼栋、单元、楼层、门牌和详细地址。
@@ -49,7 +53,7 @@ python3 app.py
mediamtx ./data/mediamtx.yml
```
-回归测试:`python3 -m unittest -v test_app test_ptz test_camera_settings test_ptz_presets` 和 `node --test test_live_player.js test_kinship.js test_calendar.js test_family_graph.js test_ui_logic.js test_recording_tools.js`。测试使用临时数据库和模拟播放器,不连接真实设备。
+回归测试:`python3 -m unittest -v test_app test_ptz test_camera_settings test_ptz_presets` 和 `node --test test_live_player.js test_kinship.js test_calendar.js test_family_graph.js test_ui_logic.js test_recording_tools.js test_theme.js`。测试使用临时数据库和模拟播放器,不连接真实设备。
访问 `http://127.0.0.1:8790/`。后端配置变量:`VISION_BIND`、`VISION_PORT`、`VISION_DATA`、`VISION_RECORDINGS`;高清转码可用 `VISION_VAAPI_DEVICE` 指定渲染设备(默认 `/dev/dri/renderD128`)。HTTPS 反向代理场景可设 `VISION_SECURE_COOKIE=1`。当前管理 API 使用管理员与个人账号分权。
diff --git a/VERSION b/VERSION
index 7906299..7e72641 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.1.21
+0.1.22
diff --git a/app.py b/app.py
index 08ffd9b..91089ad 100644
--- a/app.py
+++ b/app.py
@@ -875,7 +875,7 @@ class Handler(BaseHTTPRequestHandler):
files = {'/': 'index.html', '/app.js': 'app.js', '/live-player.js': 'live-player.js', '/style.css': 'style.css',
'/family.js': 'family.js', '/kinship.js': 'kinship.js',
'/family-graph.js': 'family-graph.js', '/family-map.js': 'family-map.js',
- '/family-map.css': 'family-map.css',
+ '/family-map.css': 'family-map.css', '/chinese-theme.css': 'chinese-theme.css',
'/family-events.js': 'family-events.js',
'/family-history.js': 'family-history.js',
'/camera-settings.js': 'camera-settings.js',
diff --git a/test_app.py b/test_app.py
index bf02601..e214ae2 100644
--- a/test_app.py
+++ b/test_app.py
@@ -105,7 +105,7 @@ class WebTests(unittest.TestCase):
self.assertEqual(headers['Cache-Control'], 'no-store')
self.assertNotIn(b'__ASSET_VERSION__', body)
assets = re.findall(r'(?:src|href)="(/[^"?]+\.(?:js|css)\?v=[^"]+)"', body.decode())
- self.assertEqual(len(assets), 18)
+ self.assertEqual(len(assets), 19)
version = (app.ROOT / 'VERSION').read_text().strip()
for asset in assets:
self.assertTrue(asset.endswith('?v=' + version))
diff --git a/test_theme.js b/test_theme.js
new file mode 100644
index 0000000..0697dde
--- /dev/null
+++ b/test_theme.js
@@ -0,0 +1,61 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const css = fs.readFileSync(path.join(__dirname, 'web/chinese-theme.css'), 'utf8');
+const html = fs.readFileSync(path.join(__dirname, 'web/index.html'), 'utf8');
+const root = css.match(/:root\s*\{([^}]+)\}/)[1];
+const token = name => root.match(new RegExp('--' + name + ':\\s*(#[0-9a-f]{6})'))[1];
+function luminance(hex) {
+ const values = hex.slice(1).match(/../g).map(v => parseInt(v, 16) / 255)
+ .map(v => v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4);
+ return values[0] * .2126 + values[1] * .7152 + values[2] * .0722;
+}
+function contrast(a, b) {
+ const x = luminance(a), y = luminance(b);
+ return (Math.max(x, y) + .05) / (Math.min(x, y) + .05);
+}
+test('paper theme is loaded last, versioned and uses only local fonts', () => {
+ const styles = [...html.matchAll(/ x[1]);
+ assert.equal(styles.at(-1), '/chinese-theme.css?v=__ASSET_VERSION__');
+ assert.doesNotMatch(css, /@import|https?:|@font-face/i);
+ assert.match(html, /name="theme-color" content="#213d34"/);
+ assert.match(html, /class="courtyard-landscape"[^>]+aria-hidden="true"/);
+});
+test('primary text, muted text and actions have at least 4.5:1 contrast', () => {
+ for (const [fg, bg] of [
+ [token('ink'), token('paper')], [token('muted'), token('paper')],
+ [token('muted'), token('panel')], ['#fffdf6', token('accent')],
+ ['#c9d1be', '#213d34'], ['#244638', '#efebdc'],
+ ['#355b3d', '#e4eddf'], ['#835428', '#f2e8d9'],
+ ['#994437', '#fbf8ef'], ['#d9dfca', '#24372b'],
+ ]) assert.ok(contrast(fg, bg) >= 4.5, `${fg} on ${bg}: ${contrast(fg, bg).toFixed(2)}`);
+});
+test('monitor fullscreen, reduced motion and mobile navigation are explicitly styled', () => {
+ assert.match(css, /#page-live:fullscreen\s*\{[^}]+color-scheme: dark/);
+ assert.match(css, /@media \(prefers-reduced-motion: reduce\)/);
+ assert.match(css, /overflow-x: auto/);
+ assert.match(css, /\.family-node\.self\s*\{[^}]+background: #f1ddb0/);
+ assert.match(css, /--timeline-paper:/);
+});
+test('recording timeline paints with theme tokens and keeps range geometry', () => {
+ const vm = require('node:vm');
+ const source = fs.readFileSync(path.join(__dirname, 'web/recording-ui.js'), 'utf8');
+ const draw = source.slice(source.indexOf('function drawTimeline()'), source.indexOf('function playbackParams('));
+ const rectangles = [];
+ const ctx = { scale() {}, beginPath() {}, moveTo() {}, lineTo() {}, stroke() {},
+ fillRect(...coordinates) { rectangles.push({ color: this.fillStyle, coordinates }); } };
+ const canvas = { clientWidth: 480, getContext: () => ctx };
+ const values = { '--timeline-paper': '#ede8dd', '--timeline-grid': '#d3ccbc', '--timeline-range': '#4c7761' };
+ const scope = vm.createContext({ $: () => canvas, window: { devicePixelRatio: 2 }, document: { documentElement: {} },
+ getComputedStyle: () => ({ getPropertyValue: name => values[name] }),
+ queryStart: new Date(0), queryEnd: new Date(60000), spans: [{ start: 10000, end: 20000 }] });
+ vm.runInContext(draw + ';drawTimeline()', scope);
+ assert.equal(canvas.width, 960);
+ assert.equal(canvas.height, 152);
+ assert.deepEqual(rectangles, [
+ { color: '#ede8dd', coordinates: [0, 0, 480, 76] },
+ { color: '#4c7761', coordinates: [80, 25, 80, 25] },
+ ]);
+ assert.equal(ctx.strokeStyle, '#d3ccbc');
+});
diff --git a/web/chinese-theme.css b/web/chinese-theme.css
new file mode 100644
index 0000000..3f3d631
--- /dev/null
+++ b/web/chinese-theme.css
@@ -0,0 +1,245 @@
+/* 赵府智家 · 庭院书卷。Keep visual tokens separate from layout and playback. */
+:root {
+ color-scheme: light;
+ --paper: #f4f0e7;
+ --ink: #2d3932;
+ --muted: #6e6b5f;
+ --line: #dcd5c6;
+ --panel: #fffdf8;
+ --accent: #315b4d;
+ --cinnabar: #9e4b3d;
+ --gold: #86682f;
+ --serif: "Songti SC", "STSong", "SimSun", serif;
+ --timeline-paper: #ede8dd;
+ --timeline-grid: #d3ccbc;
+ --timeline-range: #4c7761;
+ color: var(--ink);
+ background: var(--paper);
+ font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif;
+}
+body {
+ background-image: radial-gradient(ellipse at 85% 0, #e4e9df80, transparent 50%);
+ min-width: 0;
+}
+::selection { background: #e8d7ad; color: #2d3932; }
+button, .button, input, select, textarea, fieldset { border-radius: 5px; }
+button, .button { background: var(--accent); color: #fffdf6; font-weight: 500; transition: background .15s, border-color .15s, box-shadow .15s; }
+button:hover, .button:hover { filter: brightness(.95); }
+.secondary { background: #e8ede5; color: #315b4d; border-color: #c7d3c4; }
+.quiet { background: transparent; color: #586653; border-color: #cfcfbf; }
+input, select, textarea { color: var(--ink); background: #fffefa; border-color: #cbc5b6; }
+input::placeholder, textarea::placeholder { color: #847f72; }
+input:focus, select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px #315b4d12; }
+input[type=range], input[type=checkbox] { accent-color: var(--accent); }
+label, .family-date legend { color: #625f53; }
+h1, h2, h3 { font-family: var(--serif); font-weight: 600; }
+h1 { font-size: 34px; letter-spacing: 4px; margin-top: 17px; }
+h2 { letter-spacing: 1px; }
+.sidebar {
+ width: 222px;
+ background: #213d34;
+ background-image: linear-gradient(145deg, #2d4b3d, transparent 60%);
+ border-right: 1px solid #425344;
+ padding: 30px 18px 20px;
+ color: #ebe7d8;
+ scrollbar-width: thin;
+ scrollbar-color: #6f8271 #213d34;
+}
+.sidebar .brand { gap: 11px; padding: 4px 5px 30px; color: #f3eddf; font-family: var(--serif); font-size: 22px; letter-spacing: 2px; }
+.brand-icon { width: 44px; height: 44px; border-radius: 5px; outline: 1px solid #c6b98c66; outline-offset: 3px; }
+.brand small { font-family: "Microsoft YaHei", sans-serif; color: #c0c7b5; font-size: 9px; letter-spacing: 2px; margin-top: 10px; }
+.nav-label { color: #b2bba6; font-size: 10px; padding: 12px 13px 17px; letter-spacing: 3px; border-top: 1px solid #b9c2aa24; }
+.sidebar nav { gap: 5px; }
+.sidebar nav button { color: #c9d1be; border-radius: 4px; padding: 12px 13px; gap: 13px; position: relative; border: 1px solid transparent; background: transparent; }
+.sidebar nav button span { font-size: 13px; letter-spacing: .6px; }
+.sidebar nav button:hover { background: #ffffff09; filter: none; }
+.sidebar nav button.active { background: #efebdc; border-color: #e0d6b8; color: #244638; }
+.sidebar nav button.active::after { content: ""; width: 5px; height: 5px; background: var(--cinnabar); position: absolute; right: 13px; transform: rotate(45deg); }
+.sidebar .side-note { padding: 22px 5px 8px; color: #c4ccb9; font-size: 11px; }
+.sidebar .dot { background: #afc39c; width: 5px; height: 5px; }
+.sidebar #current-user { color: #d3d7c8; padding: 10px 5px 0; border-top: 1px solid #b9c2aa24; }
+.sidebar .quiet { color: #d1d6c6; border-color: #8b9a813f; padding: 9px; font-size: 11px; }
+.sidebar #account-password { margin-bottom: 8px; }
+.sidebar :focus-visible { outline-color: #e7c887; }
+main { margin-left: 222px; padding: 30px 38px 22px; max-width: 1900px; }
+main > header { min-height: 142px; padding: 13px 0 23px; margin-bottom: 25px; border-bottom: 1px solid #b4b5a2; position: relative; gap: 24px; isolation: isolate; }
+main > header::after { content: ""; position: absolute; bottom: -2px; left: 0; height: 3px; width: 48px; background: var(--cinnabar); }
+main > header > div { position: relative; z-index: 1; }
+.eyebrow { color: var(--gold); font-size: 10px; letter-spacing: 3px; }
+header p { color: #686f60; font-size: 12px; letter-spacing: .5px; }
+.header-status { color: #666e5e; gap: 12px; font-size: 11px; flex-shrink: 0; }
+.courtyard-landscape { position: absolute; width: 330px; height: 125px; right: 5px; bottom: 0; z-index: 0; pointer-events: none; opacity: .55; }
+.badge { background: #e4eddf; color: #355b3d; border: 1px solid #c8d6be; border-radius: 3px; font-size: 10px; }
+.badge.off { background: #f2e8d9; color: #835428; border-color: #dfc6a3; }
+.metrics { background: #fcfaf4; border-radius: 6px; box-shadow: 0 3px 12px #3a402905; }
+.metrics > div { padding: 20px 22px; align-items: baseline; }
+.metrics span { color: #6f7260; }
+.metrics strong { font-family: Georgia, var(--serif); color: #365846; font-size: 29px; }
+.filter-row { color: #6e6b5f; margin: 24px 0; }
+.section-head { margin-top: 25px; }
+.section-head small { color: #8a7859; font-size: 9px; letter-spacing: 2px; }
+.wall-toolbar { padding: 12px 15px; background: #eae8dc; border: 1px solid var(--line); border-radius: 5px; gap: 14px; }
+.wall-toolbar select { background: #f9f6ed; }
+.live-grid { gap: 22px; }
+.camera-card, .site-card, .panel, .empty, .family-canvas-panel { border-radius: 6px; box-shadow: 0 4px 18px #39422a05; }
+.camera-card { border-color: #d2cebe; }
+.camera-meta { padding: 17px 18px 13px; }
+.camera-meta strong { font-family: var(--serif); font-size: 16px; letter-spacing: .5px; }
+.camera-meta p { color: #737464; margin-top: 7px; }
+.camera-actions { flex-wrap: wrap; gap: 7px; padding-bottom: 16px; }
+.camera-actions .quiet { border-color: #dbd9cb; color: #52604d; background: #faf9f2; }
+.video-panel { border-radius: 5px; border-color: #c1c6b8; }
+.video-empty { color: #b0bdae; background: radial-gradient(ellipse at center, #243d34 0, transparent 70%); }
+.video-empty .lens { color: #a5bca4; }
+.video-error { color: #f6d7c1; background: #472c26ed; }
+.empty { color: #6c7462; border-color: #c3c6b6; }
+.empty h3 { color: #384e3b; font-size: 21px; }
+.panel { padding: 24px; }
+.time-labels { color: #6e705e; }
+.recording-item { color: #35533f; background: #f9f7ee; border-color: #d5d1c0; }
+.recording-item small { color: #737261; }
+.scan-chip { background: #fffdf7; border-radius: 5px; }
+.device-table { border-radius: 5px; }
+.device-table th { color: #6a6d5c; background: #ecebde; }
+.device-table td small { color: #737261; }
+.device-table tbody tr:hover { background: #f2f1e7; }
+.site-card h3 { font-size: 20px; }
+.site-card dt { color: #737261; }
+.site-card dd { color: #384e3b; }
+.audit-item { color: #56654f; }
+.audit-item small { color: #767461; }
+.banner { color: #784c26; background: #f6e9cf; border-color: #dfc797; }
+dialog { background: #fbf8ef; color: var(--ink); border: 1px solid #d5c8ac; border-radius: 8px; box-shadow: 0 28px 100px #172c3455; padding: 30px; }
+dialog::backdrop { background: #20382cb8; backdrop-filter: blur(5px); }
+.dialog-heading { border-bottom: 1px solid var(--line); padding-bottom: 20px; gap: 16px; }
+.dialog-heading h2 { font-size: 23px; }
+.dialog-footer { gap: 10px; flex-wrap: wrap; }
+#auth-dialog { border-top: 4px solid var(--cinnabar); padding: 38px; background-image: radial-gradient(ellipse at 100% 0, #e4e9dd, transparent 65%); }
+#auth-dialog .brand-icon { width: 52px; height: 52px; }
+#auth-form h2 { font-size: 32px; letter-spacing: 3px; margin-top: 26px; }
+#auth-form p { color: #6b725d; }
+.form-error { color: #994437; }
+footer { color: #716b5b; font-size: 10px; letter-spacing: .5px; }
+#toast { background: #2a4c3c; color: #f3efdc; border-color: #819672; border-radius: 5px; box-shadow: 0 8px 25px #30433726; max-width: calc(100vw - 30px); }
+.ptz-panel { background: #f8f5eb; border-color: #b9c5ad; border-radius: 7px; box-shadow: 0 12px 50px #26392d44; color: var(--ink); }
+.ptz-pad [data-ptz-action=stop] { color: #8b4236; background: #efdbd0; border-color: #d5b2a4; }
+#preset-confirmation { background: #f4e2d5; }
+#snapshot-image { border-radius: 4px; }
+/* Family register: paper, ink lines and a restrained gold highlight for oneself. */
+.family-canvas-panel { background: #fcfaf3; border-color: #c7cbb7; }
+.family-map-heading { padding-top: 24px; }
+.family-map-heading h2 { letter-spacing: 3px; font-size: 25px; }
+.family-view-tabs { background: #f1eee2; border-color: #d7d0bf; border-radius: 4px; }
+.family-view-tabs button { color: #606650; border-radius: 2px; }
+.family-view-tabs [aria-pressed=true] { background: #365945; color: #fffbed; }
+.family-map-viewport { background-color: #efece0; background-image: radial-gradient(#c9cbbb 1px, transparent 1px); scrollbar-color: #8b9a7b #e8e6d8; }
+.family-map-lines marker path { fill: #678b71; }
+.family-edge { stroke: #94a58a; }
+.family-edge.focused { stroke: #446b50; }
+.family-edge.spouse { stroke: #a9854f; }
+.family-edge.nonbiological { stroke: #93829e; }
+.family-node { background: #fffdf6; border-color: #c9cdb9; border-radius: 5px; color: #304632; box-shadow: 0 3px 12px #3943250d; }
+.family-node > strong { font-family: var(--serif); font-size: 17px; }
+.family-node-avatar { background: #e8ecdf; color: #536e45; border-radius: 4px; }
+.family-node .family-node-relation { color: #53664a; }
+.family-node > small, .family-node-flags > small { color: #6f775f; }
+.family-node.self { background: #f1ddb0; border-color: #af873f; box-shadow: 0 5px 18px #b4872425; }
+.family-node.self .family-node-avatar { background: #f8eac9; }
+.family-node.selected { outline-color: #426c50; }
+.family-node.search-hit::after { background: #596b78; }
+.self-badge { background: #94682f; color: #fff8e9; border-radius: 2px; }
+.family-map-footer { border-color: #d0d3c0; }
+.family-map-legend { color: #69705b; }
+.family-map-footer #family-map-note:not(:empty) { color: #87652f; }
+.family-inspector.is-self { background: linear-gradient(135deg, #f3e3bd, #fcfaf1 65%); border-color: #c3a66b; }
+.family-self-heading { color: #88652d; letter-spacing: 1px; }
+.family-register { background: #eeecdf; }
+.person-card { color: #344732; border-color: #c9cbb9; }
+.person-card.selected { background: #e6ecdc; outline-color: #66845b; }
+.person-card.self { background: #f1ddb0; border-color: #b89553; color: #5f461f; }
+.person-card.self span:not(.self-badge), .person-card.self small { color: #765b2e; }
+.family-tree ul { border-color: #a2b197; }
+.family-workspace:is(:fullscreen, .family-expanded) { background: var(--paper); }
+.family-event-filters { background: #f0eee2; }
+.timeline-year { color: #456344; font-family: var(--serif); }
+.timeline-event::before { border-color: #b7c2a5; }
+.timeline-event::after { background: #768d5f; box-shadow: 0 0 0 4px #e7ebdc; }
+.timeline-event > time { color: #6c745d; }
+.timeline-event-body { background: #fffdf6; border-color: #d3d6c2; border-radius: 5px; }
+.timeline-event:hover .timeline-event-body { background: #f7f7ec; border-color: #8caa78; }
+.timeline-event-body > strong { color: #344d32; font-family: var(--serif); font-size: 18px; }
+.timeline-event-status, .timeline-summary, .timeline-event-body > small { color: #6b725b; }
+.timeline-read { color: #41623b; }
+.timeline-event.draft .timeline-event-body { background: #efede3; }
+.timeline-event.draft::after { background: #a28a62; }
+.event-detail-meta, .person-event-link time { color: #71745e; }
+.event-lead { background: #eaecdd; border-color: #8e9f77; color: #46553b; }
+.event-article { color: #424c3a; }
+.person-event-link { color: #455a3c; }
+.history-columns dl > div { border-color: #d9d6c4; }
+.history-changed { background: #e8ecda; border-radius: 4px; }
+#history-confirm { border-color: #b3be98; border-radius: 5px; background: #efefdf; }
+/* The monitoring wall stays dark to keep attention on live video. */
+#page-live:fullscreen { --muted: #c0c9b4; --line: #52614d; --panel: #25352c; color: #efecdd; background: #111c16; color-scheme: dark; }
+#page-live:fullscreen .wall-toolbar { background: #24372b; border-color: #40543e; }
+#page-live:fullscreen label { color: #d9deca; }
+#page-live:fullscreen select { background: #1a2a20; color: #ebeede; border-color: #65765a; }
+#page-live:fullscreen .quiet { color: #d9dfca; border-color: #66765a; background: transparent; }
+#page-live:fullscreen .camera-card { border-color: #394f38; }
+#page-live:fullscreen .camera-actions { background: #17251ee8; }
+#page-live:fullscreen .ptz-panel { --muted: #6e6b5f; --line: #dcd5c6; color-scheme: light; }
+#page-live:fullscreen .ptz-panel label, #page-live:fullscreen .ptz-panel .quiet { color: #586653; border-color: #cfcfbf; }
+@media (min-width: 1600px) { main { padding-left: 46px; padding-right: 46px; } }
+@media (max-width: 1100px) {
+ .sidebar { width: 200px; padding: 27px 14px 18px; }
+ .sidebar .brand { font-size: 20px; gap: 9px; }
+ .brand-icon { width: 38px; height: 38px; }
+ main { margin-left: 200px; padding: 24px; }
+ .metrics > div { padding: 18px 14px; }
+ .metrics strong { font-size: 25px; }
+ .courtyard-landscape { width: 260px; opacity: .4; }
+}
+@media (max-width: 760px) {
+ .sidebar { position: static; width: auto; padding: 22px 17px 14px; display: grid; grid-template-columns: 1fr auto; gap: 0 10px; overflow: visible; }
+ .sidebar .brand { padding: 2px 4px 20px; font-size: 22px; grid-column: 1/-1; }
+ .brand-icon { width: 38px; height: 38px; }
+ .brand small { font-size: 9px; }
+ .sidebar nav { grid-column: 1/-1; display: flex; overflow-x: auto; gap: 5px; padding-bottom: 12px; scrollbar-width: thin; scrollbar-color: #819573 #213d34; }
+ .sidebar nav button { padding: 10px 12px; gap: 7px; flex-shrink: 0; }
+ .sidebar nav button span { font-size: 12px; }
+ .sidebar nav button.active::after { display: none; }
+ .sidebar #current-user { position: static; grid-column: 1/-1; margin: 4px 0 10px; padding-top: 12px; font-size: 11px; }
+ .sidebar #account-password, .sidebar #logout { margin: 0; font-size: 11px; padding: 8px 12px; align-self: center; width: fit-content; }
+ .sidebar #logout { justify-self: end; }
+ main { margin: 0; padding: 20px 17px; }
+ main > header { min-height: 132px; padding-top: 9px; gap: 10px; margin-bottom: 20px; }
+ h1 { font-size: 28px; letter-spacing: 3px; }
+ .eyebrow { font-size: 9px; letter-spacing: 2px; }
+ .header-status { font-size: 9px; gap: 9px; max-width: 123px; padding-top: 2px; }
+ .header-status .badge { font-size: 9px; }
+ header p { max-width: 205px; font-size: 11px; line-height: 1.8; }
+ .courtyard-landscape { width: 170px; height: 90px; right: 0; opacity: .38; }
+ .metrics > div { padding: 16px; }
+ .metrics strong { font-size: 25px; }
+ .metrics > div:nth-last-child(-n+2) { border-bottom: 0; }
+ .filter-row { margin: 20px 0; }
+ .filter-row select { max-width: 155px; }
+ .filter-row > button { padding: 9px; }
+ .section-head { flex-wrap: wrap; }
+ .section-head > div { gap: 10px; }
+ .wall-toolbar { padding: 11px; gap: 10px; }
+ .wall-toolbar label { font-size: 12px; }
+ #wall-fullscreen { padding: 10px 12px; }
+ .camera-meta { padding: 14px; }
+ .camera-meta strong { font-size: 15px; }
+ .camera-actions { padding: 0 14px 14px; gap: 8px; }
+ .camera-actions button { padding: 9px 11px; }
+ .panel, .site-card { padding: 18px; }
+ .family-map-heading h2 { font-size: 23px; }
+ dialog { padding: 22px; }
+ #auth-dialog { padding: 30px 26px; }
+ .dialog-heading h2 { font-size: 21px; }
+ .dialog-footer { gap: 8px; }
+ footer { gap: 16px; flex-wrap: wrap; font-size: 9px; }
+}
+@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } }
diff --git a/web/index.html b/web/index.html
index 6c16073..2c59992 100644
--- a/web/index.html
+++ b/web/index.html
@@ -2,7 +2,7 @@
- 赵府智家 · 家庭空间
+ 赵府智家 · 家庭空间
@@ -10,8 +10,8 @@
- ZHAOFU HOME / FAMILY
实时画面
从一个空间,看到每一个位置。
+ 赵氏家宅 · 起居有序
实时画面
从一个空间,看到每一个位置。
家庭与公司,分别管理
- 现场画面 LIVE VIEW
主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。
常用位置
调整位置后可另存为新预置位。本版不覆盖已有位置,避免设备固件拒绝或误改。
+ 现场画面 守望
主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。
常用位置
调整位置后可另存为新预置位。本版不覆盖已有位置,避免设备固件拒绝或误改。
- 回到那一刻 PLAYBACK
+ 回到那一刻 光阴
选择日期,查看实际录像时段
当日时间轴 有录像的时段 · 点击定位
00:0006:0012:0018:0024:00
-
- 摄像头对象 CAMERA ASSETS
一台实体摄像头对应一个对象,双镜头在对象下建立两个镜头通道;每个镜头分别有主码流和子码流。
- 录像机 RECORDERS
统一保存录像机地址与账号,镜头通道关联录像机。实时取流使用 RTSP;读取录像机已有历史录像,需要该型号的回放接口适配。
- 空间档案 PLACES
为家庭、公司或其他场所建立地址档案,再关联摄像头。地区、小区、楼栋和门牌都是独立属性。
- 录像与存储 STORAGE
运行状态
开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用主码流高清画面(最高 1920×1080、15 帧/秒),也可切换子码流流畅画面(最高 768×432、10 帧/秒)。本机录像保留设备原始编码。
最近操作
+
+ 摄像头对象 器物
一台实体摄像头对应一个对象,双镜头在对象下建立两个镜头通道;每个镜头分别有主码流和子码流。
+ 录像机 存影
统一保存录像机地址与账号,镜头通道关联录像机。实时取流使用 RTSP;读取录像机已有历史录像,需要该型号的回放接口适配。
+ 空间档案 家宅
为家庭、公司或其他场所建立地址档案,再关联摄像头。地区、小区、楼栋和门牌都是独立属性。
+ 录像与存储 留存
运行状态
开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用主码流高清画面(最高 1920×1080、15 帧/秒),也可切换子码流流畅画面(最高 768×432、10 帧/秒)。本机录像保留设备原始编码。
最近操作
- 家族的来处与延续 FAMILY REGISTER
+ 家族的来处与延续 世系 · 传承
从祖辈到后辈,一张图看全家。点击人物查看档案,暖金色卡片是当前账号关联的本人。
- FAMILY CONNECTIONS
全家关系图
+ 枝叶相连 · 世代相承
全家关系图
@@ -66,7 +66,7 @@
账号与人物关系 ACCOUNTS
亲属称呼用于描述人物关系。摄像头空间权限、家谱权限和管理员身份分别设置。无需为每一位家谱人物创建账号。
-
+
@@ -76,7 +76,7 @@
-
+
diff --git a/web/recording-ui.js b/web/recording-ui.js
index 658ee88..3462c6e 100644
--- a/web/recording-ui.js
+++ b/web/recording-ui.js
@@ -21,9 +21,9 @@ async function queryRecordings(){
}catch(e){if(!e.stale&&generation===playbackGeneration)$('#play-info').textContent=e.message;}
}
function drawTimeline(){
- const c=$('#timeline'),w=c.clientWidth;if(!w)return;const scale=window.devicePixelRatio||1;c.width=w*scale;c.height=76*scale;const ctx=c.getContext('2d');ctx.scale(scale,scale);ctx.fillStyle='#101f25';ctx.fillRect(0,0,w,76);ctx.strokeStyle='#2b454e';
+ const c=$('#timeline'),w=c.clientWidth;if(!w)return;const scale=window.devicePixelRatio||1;c.width=w*scale;c.height=76*scale;const ctx=c.getContext('2d');ctx.scale(scale,scale);const theme=getComputedStyle(document.documentElement);ctx.fillStyle=theme.getPropertyValue('--timeline-paper').trim()||'#ede8dd';ctx.fillRect(0,0,w,76);ctx.strokeStyle=theme.getPropertyValue('--timeline-grid').trim()||'#d3ccbc';
for(let i=0;i<=24;i++){const x=w*i/24;ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,i%6===0?76:12);ctx.stroke();}
- if(!queryStart||!queryEnd)return;ctx.fillStyle='#72cdb5';const total=queryEnd-queryStart;
+ if(!queryStart||!queryEnd)return;ctx.fillStyle=theme.getPropertyValue('--timeline-range').trim()||'#4c7761';const total=queryEnd-queryStart;
for(const s of spans){const a=(s.start-queryStart)/total,b=(s.end-queryStart)/total;if(b>a)ctx.fillRect(a*w,25,Math.max(2,(b-a)*w),25);}
}
function playbackParams(range){return new URLSearchParams({camera:queryCamera,start:new Date(range.start).toISOString(),duration:String((range.end-range.start)/1000)});}