feat: introduce Chinese courtyard visual theme v0.1.22
This commit is contained in:
@@ -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 使用管理员与个人账号分权。
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
+1
-1
@@ -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))
|
||||
|
||||
@@ -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(/<link rel="stylesheet" href="([^"]+)"/g)].map(x => 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');
|
||||
});
|
||||
@@ -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; } }
|
||||
+15
-15
@@ -2,7 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>赵府智家 · 家庭空间</title><link rel="icon" type="image/png" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><meta name="theme-color" content="#10191d"><link rel="stylesheet" href="/style.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/family-map.css?v=__ASSET_VERSION__">
|
||||
<title>赵府智家 · 家庭空间</title><link rel="icon" type="image/png" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><meta name="theme-color" content="#213d34"><link rel="stylesheet" href="/style.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/family-map.css?v=__ASSET_VERSION__"><link rel="stylesheet" href="/chinese-theme.css?v=__ASSET_VERSION__">
|
||||
<script defer src="/vendor/hls.min.js?v=__ASSET_VERSION__"></script><script defer src="/live-player.js?v=__ASSET_VERSION__"></script><script defer src="/kinship.js?v=__ASSET_VERSION__"></script><script defer src="/family-graph.js?v=__ASSET_VERSION__"></script><script defer src="/calendar.js?v=__ASSET_VERSION__"></script><script defer src="/app.js?v=__ASSET_VERSION__"></script><script defer src="/monitor.js?v=__ASSET_VERSION__"></script><script defer src="/date-fields.js?v=__ASSET_VERSION__"></script><script defer src="/family-map.js?v=__ASSET_VERSION__"></script><script defer src="/family.js?v=__ASSET_VERSION__"></script><script defer src="/family-events.js?v=__ASSET_VERSION__"></script>
|
||||
<script defer src="/family-history.js?v=__ASSET_VERSION__"></script>
|
||||
<script defer src="/camera-settings.js?v=__ASSET_VERSION__"></script>
|
||||
@@ -10,8 +10,8 @@
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="/"><img class="brand-icon" src="/zhao-icon.png" alt="赵字古文字风格标识"><span>赵府智家<small>HOME / 本地家庭空间</small></span></a>
|
||||
<div class="nav-label">工作空间</div>
|
||||
<a class="brand" href="/"><img class="brand-icon" src="/zhao-icon.png" alt="赵字古文字风格标识"><span>赵府智家<small>家和 · 岁岁安宁</small></span></a>
|
||||
<div class="nav-label">家宅 · 起居</div>
|
||||
<nav aria-label="主导航">
|
||||
<button data-page="live" class="active">◫ <span>实时画面</span></button>
|
||||
<button data-page="playback">◷ <span>录像回放</span></button>
|
||||
@@ -25,31 +25,31 @@
|
||||
<p id="current-user" class="description"></p><button id="account-password" class="quiet" hidden>修改我的密码</button><button id="logout" class="quiet">退出登录</button>
|
||||
</aside>
|
||||
<main>
|
||||
<header><div><div class="eyebrow">ZHAOFU HOME / FAMILY</div><h1 id="page-title">实时画面</h1><p id="page-note">从一个空间,看到每一个位置。</p></div><div class="header-status"><span id="service-status" class="badge">等待连接</span><span id="clock"></span></div></header>
|
||||
<header><svg class="courtyard-landscape" viewBox="0 0 360 130" fill="none" aria-hidden="true"><path d="M0 124 55 86 76 99 134 43 166 78 197 56 247 102 282 81 337 121" fill="#d7dfcd"/><path d="M60 127 128 89 158 107 214 71 273 115 312 96 359 126" fill="#bdcbb6" fill-opacity=".48"/><path d="m12 125 64-40 17 13 42-46 30 37m49-18 29 28 13-5 28 21" stroke="#879b7d" stroke-width=".8"/><circle cx="269" cy="47" r="16" fill="#a25440" fill-opacity=".48"/><path d="M21 126h332" stroke="#81936e" stroke-width=".7"/></svg><div><div class="eyebrow">赵氏家宅 · 起居有序</div><h1 id="page-title">实时画面</h1><p id="page-note">从一个空间,看到每一个位置。</p></div><div class="header-status"><span id="service-status" class="badge">等待连接</span><span id="clock"></span></div></header>
|
||||
<div id="banner" class="banner" hidden></div>
|
||||
<section class="metrics"><div><span>摄像头</span><strong id="metric-all">—</strong></div><div><span>已接通码流</span><strong id="metric-ready">—</strong></div><div><span>录像占用</span><strong id="metric-used">—</strong></div><div><span>空间档案</span><strong id="metric-sites">—</strong></div></section>
|
||||
<div class="filter-row"><label>空间范围 <select id="site-filter"><option value="">全部空间</option></select></label><span id="filter-caption">家庭与公司,分别管理</span><button id="refresh" class="quiet">↻ 刷新状态</button></div>
|
||||
<section id="page-live" class="page"><div class="section-head"><h2>现场画面 <small>LIVE VIEW</small></h2><div><label>清晰度 <select id="quality"><option value="hd">主码流</option><option value="compat">子码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p><div class="wall-toolbar"><label>分屏 <select id="wall-layout"><option value="auto">自适应</option><option value="1">1 画面</option><option value="2">2 画面</option><option value="4">4 画面</option><option value="6">6 画面</option><option value="9">9 画面</option><option value="16">16 画面</option></select></label><label class="check"><input id="wall-arrange" type="checkbox">调整顺序</label><div class="wall-paging"><button id="wall-prev" class="quiet" aria-label="上一组画面">‹</button><span id="wall-page"></span><button id="wall-next" class="quiet" aria-label="下一组画面">›</button></div><span id="wall-clock"></span><button id="wall-fullscreen">全屏监控</button></div><div id="live-grid" class="live-grid"></div><section id="ptz-panel" class="ptz-panel" hidden><div><strong id="ptz-title">云台</strong><p id="ptz-status" role="status"></p></div><div class="ptz-pad"><button data-ptz-action="up" aria-label="云台向上">↑</button><button data-ptz-action="left" aria-label="云台向左">←</button><button data-ptz-action="stop">停止</button><button data-ptz-action="right" aria-label="云台向右">→</button><button data-ptz-action="down" aria-label="云台向下">↓</button></div><div id="ptz-presets"><div class="section-head"><strong>常用位置 <small id="preset-count"></small></strong><button id="preset-reload" class="quiet">重新读取</button></div><p id="preset-status" role="status"></p><div id="preset-list"></div><p class="description">调整位置后可另存为新预置位。本版不覆盖已有位置,避免设备固件拒绝或误改。</p><form id="preset-form" hidden><label>当前位置名称<input id="preset-name" required maxlength="40" placeholder="例如:大门、院子"></label><button type="submit">保存当前位置</button></form><div id="preset-confirmation" hidden><p id="preset-confirm-text"></p><button id="preset-confirm-yes">确认</button><button id="preset-confirm-cancel" class="quiet">取消</button></div></div><button id="ptz-close" class="quiet">收起云台</button></section></section>
|
||||
<section id="page-live" class="page"><div class="section-head"><h2>现场画面 <small>守望</small></h2><div><label>清晰度 <select id="quality"><option value="hd">主码流</option><option value="compat">子码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p><div class="wall-toolbar"><label>分屏 <select id="wall-layout"><option value="auto">自适应</option><option value="1">1 画面</option><option value="2">2 画面</option><option value="4">4 画面</option><option value="6">6 画面</option><option value="9">9 画面</option><option value="16">16 画面</option></select></label><label class="check"><input id="wall-arrange" type="checkbox">调整顺序</label><div class="wall-paging"><button id="wall-prev" class="quiet" aria-label="上一组画面">‹</button><span id="wall-page"></span><button id="wall-next" class="quiet" aria-label="下一组画面">›</button></div><span id="wall-clock"></span><button id="wall-fullscreen">全屏监控</button></div><div id="live-grid" class="live-grid"></div><section id="ptz-panel" class="ptz-panel" hidden><div><strong id="ptz-title">云台</strong><p id="ptz-status" role="status"></p></div><div class="ptz-pad"><button data-ptz-action="up" aria-label="云台向上">↑</button><button data-ptz-action="left" aria-label="云台向左">←</button><button data-ptz-action="stop">停止</button><button data-ptz-action="right" aria-label="云台向右">→</button><button data-ptz-action="down" aria-label="云台向下">↓</button></div><div id="ptz-presets"><div class="section-head"><strong>常用位置 <small id="preset-count"></small></strong><button id="preset-reload" class="quiet">重新读取</button></div><p id="preset-status" role="status"></p><div id="preset-list"></div><p class="description">调整位置后可另存为新预置位。本版不覆盖已有位置,避免设备固件拒绝或误改。</p><form id="preset-form" hidden><label>当前位置名称<input id="preset-name" required maxlength="40" placeholder="例如:大门、院子"></label><button type="submit">保存当前位置</button></form><div id="preset-confirmation" hidden><p id="preset-confirm-text"></p><button id="preset-confirm-yes">确认</button><button id="preset-confirm-cancel" class="quiet">取消</button></div></div><button id="ptz-close" class="quiet">收起云台</button></section></section>
|
||||
<section id="page-playback" class="page" hidden>
|
||||
<div class="section-head"><h2>回到那一刻 <small>PLAYBACK</small></h2></div>
|
||||
<div class="section-head"><h2>回到那一刻 <small>光阴</small></h2></div>
|
||||
<div class="panel playback-controls"><label>摄像头<select id="play-camera"></select></label><label>录像来源<select id="archive-source"><option value="recorder">录像机原有录像</option><option value="local">小电脑本机录像</option></select></label><label>日期<input id="play-date" type="date"></label><button id="query-recordings">查找录像</button><span id="play-info">选择日期,查看实际录像时段</span></div>
|
||||
<div class="video-panel"><video id="play-video" controls playsinline></video><div id="play-empty" class="video-empty">选择一个有录像的时间段</div></div>
|
||||
<div class="panel"><div class="timeline-head"><strong>当日时间轴</strong><span><i class="legend"></i> 有录像的时段 · 点击定位</span></div><canvas id="timeline" height="76" aria-label="录像时间轴"></canvas><div class="time-labels"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div><div class="seek-row"><label>定位时间 <input id="seek-time" type="time" step="1" value="12:00:00"></label><button id="seek-button" class="secondary">定位播放</button><a id="download" class="button secondary" hidden>下载原始片段</a></div></div>
|
||||
<div class="panel"><div class="playback-tools"><label class="check"><input id="play-autonext" type="checkbox" checked>自动续播下一时段(跳过空档)</label><button id="play-snapshot" class="quiet">回放截图</button><span id="play-position" role="status"></span></div><details class="clip-editor"><summary>选取并下载指定片段</summary><form id="clip-form"><div class="form-grid"><label>开始时间<input id="clip-start" type="datetime-local" step="1" required></label><label>结束时间<input id="clip-end" type="datetime-local" step="1" required></label></div><div class="playback-tools"><button type="button" class="quiet" data-clip-mark="start">当前位置作起点</button><button type="button" class="quiet" data-clip-mark="end">当前位置作终点</button><button type="submit">生成下载链接</button><a id="clip-download" class="button secondary" hidden>下载所选片段</a></div><p id="clip-status" role="status" class="description"></p><p class="description">每段最多一小时,不可跨越无录像时段。下载保留原始编码,剪辑边界可能受设备关键帧影响。</p></form></details></div><div id="recording-list" class="recording-list"></div>
|
||||
</section>
|
||||
<section id="page-cameras" class="page" hidden><div class="section-head"><h2>镜头通道 <small>CHANNELS</small></h2><button data-action="new-camera">+ 添加镜头通道</button></div><div class="panel discovery"><div><strong>发现局域网设备</strong><p>探测 RTSP 服务,添加后用设备账号连接。</p></div><input id="scan-network" placeholder="例如 192.168.1.0/24" aria-label="局域网网段"><button id="scan-button" class="secondary">开始探测</button></div><div id="scan-result"></div><div id="camera-list"></div></section>
|
||||
<section id="page-objects" class="page" hidden><div class="section-head"><h2>摄像头对象 <small>CAMERA ASSETS</small></h2><button data-action="new-asset">+ 新建摄像头对象</button></div><p class="description">一台实体摄像头对应一个对象,双镜头在对象下建立两个镜头通道;每个镜头分别有主码流和子码流。</p><div id="asset-list" class="site-grid"></div></section>
|
||||
<section id="page-recorders" class="page" hidden><div class="section-head"><h2>录像机 <small>RECORDERS</small></h2><button data-action="new-recorder">+ 添加录像机</button></div><p class="description">统一保存录像机地址与账号,镜头通道关联录像机。实时取流使用 RTSP;读取录像机已有历史录像,需要该型号的回放接口适配。</p><div id="recorder-list" class="site-grid"></div></section>
|
||||
<section id="page-sites" class="page" hidden><div class="section-head"><h2>空间档案 <small>PLACES</small></h2><button data-action="new-site">+ 新建空间</button></div><p class="description">为家庭、公司或其他场所建立地址档案,再关联摄像头。地区、小区、楼栋和门牌都是独立属性。</p><div id="site-list" class="site-grid"></div></section>
|
||||
<section id="page-storage" class="page" hidden><div class="section-head"><h2>录像与存储 <small>STORAGE</small></h2></div><div class="storage-grid"><form id="storage-form" class="panel"><h3>录像保留策略</h3><label>保留天数<input name="retentionDays" type="number" min="1" max="365" required></label><label>录像容量上限(GB)<input name="maxGB" type="number" min="5" max="100000" required></label><label>磁盘预留空间(GB)<input name="reserveGB" type="number" min="2" max="1000" required></label><p class="description">超过保留天数的录像会自动删除。达到容量上限或预留空间不足时暂停录像;调整策略后自动恢复。容量每 20 秒检查一次,可能有少量超出。</p><button type="submit">保存策略</button><span class="form-error"></span></form><div class="panel"><h3>运行状态</h3><dl id="storage-status"></dl><p class="description">开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用主码流高清画面(最高 1920×1080、15 帧/秒),也可切换子码流流畅画面(最高 768×432、10 帧/秒)。本机录像保留设备原始编码。</p><h3>最近操作</h3><div id="audit-list"></div></div></div></section>
|
||||
<section id="page-cameras" class="page" hidden><div class="section-head"><h2>镜头通道 <small>通道</small></h2><button data-action="new-camera">+ 添加镜头通道</button></div><div class="panel discovery"><div><strong>发现局域网设备</strong><p>探测 RTSP 服务,添加后用设备账号连接。</p></div><input id="scan-network" placeholder="例如 192.168.1.0/24" aria-label="局域网网段"><button id="scan-button" class="secondary">开始探测</button></div><div id="scan-result"></div><div id="camera-list"></div></section>
|
||||
<section id="page-objects" class="page" hidden><div class="section-head"><h2>摄像头对象 <small>器物</small></h2><button data-action="new-asset">+ 新建摄像头对象</button></div><p class="description">一台实体摄像头对应一个对象,双镜头在对象下建立两个镜头通道;每个镜头分别有主码流和子码流。</p><div id="asset-list" class="site-grid"></div></section>
|
||||
<section id="page-recorders" class="page" hidden><div class="section-head"><h2>录像机 <small>存影</small></h2><button data-action="new-recorder">+ 添加录像机</button></div><p class="description">统一保存录像机地址与账号,镜头通道关联录像机。实时取流使用 RTSP;读取录像机已有历史录像,需要该型号的回放接口适配。</p><div id="recorder-list" class="site-grid"></div></section>
|
||||
<section id="page-sites" class="page" hidden><div class="section-head"><h2>空间档案 <small>家宅</small></h2><button data-action="new-site">+ 新建空间</button></div><p class="description">为家庭、公司或其他场所建立地址档案,再关联摄像头。地区、小区、楼栋和门牌都是独立属性。</p><div id="site-list" class="site-grid"></div></section>
|
||||
<section id="page-storage" class="page" hidden><div class="section-head"><h2>录像与存储 <small>留存</small></h2></div><div class="storage-grid"><form id="storage-form" class="panel"><h3>录像保留策略</h3><label>保留天数<input name="retentionDays" type="number" min="1" max="365" required></label><label>录像容量上限(GB)<input name="maxGB" type="number" min="5" max="100000" required></label><label>磁盘预留空间(GB)<input name="reserveGB" type="number" min="2" max="1000" required></label><p class="description">超过保留天数的录像会自动删除。达到容量上限或预留空间不足时暂停录像;调整策略后自动恢复。容量每 20 秒检查一次,可能有少量超出。</p><button type="submit">保存策略</button><span class="form-error"></span></form><div class="panel"><h3>运行状态</h3><dl id="storage-status"></dl><p class="description">开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用主码流高清画面(最高 1920×1080、15 帧/秒),也可切换子码流流畅画面(最高 768×432、10 帧/秒)。本机录像保留设备原始编码。</p><h3>最近操作</h3><div id="audit-list"></div></div></div></section>
|
||||
<section id="page-family" class="page" hidden>
|
||||
<div class="section-head"><h2>家族的来处与延续 <small>FAMILY REGISTER</small></h2><div><button id="family-reload" class="quiet">重新载入</button><button id="family-export" class="quiet">导出家谱</button><button id="new-person">+ 录入人物</button></div></div>
|
||||
<div class="section-head"><h2>家族的来处与延续 <small>世系 · 传承</small></h2><div><button id="family-reload" class="quiet">重新载入</button><button id="family-export" class="quiet">导出家谱</button><button id="new-person">+ 录入人物</button></div></div>
|
||||
<p class="description">从祖辈到后辈,一张图看全家。点击人物查看档案,暖金色卡片是当前账号关联的本人。</p>
|
||||
<div class="panel family-toolbar"><label>关系参照人物<select id="family-reference"></select></label><label>查找人物<input id="family-search" type="search" placeholder="姓名、别名或籍贯"></label><button id="family-find" class="quiet" hidden>定位搜索结果</button><label class="check"><input id="family-archived" type="checkbox">显示归档人物</label><span id="family-count"></span></div>
|
||||
<div id="family-error" class="form-error" role="status"></div>
|
||||
<div id="family-workspace" class="family-workspace">
|
||||
<section id="family-canvas-panel" class="family-canvas-panel" aria-label="全家关系图">
|
||||
<div class="family-map-heading"><div><span class="eyebrow">FAMILY CONNECTIONS</span><h2 id="family-view-title">全家关系图</h2></div><div class="family-view-tabs" role="group" aria-label="家谱显示方式"><button id="family-view-tree" class="quiet" aria-pressed="true">关系图</button><button id="family-view-list" class="quiet" aria-pressed="false">人物名册</button><button id="family-view-timeline" class="quiet" aria-pressed="false">家族时间线</button></div></div>
|
||||
<div class="family-map-heading"><div><span class="eyebrow">枝叶相连 · 世代相承</span><h2 id="family-view-title">全家关系图</h2></div><div class="family-view-tabs" role="group" aria-label="家谱显示方式"><button id="family-view-tree" class="quiet" aria-pressed="true">关系图</button><button id="family-view-list" class="quiet" aria-pressed="false">人物名册</button><button id="family-view-timeline" class="quiet" aria-pressed="false">家族时间线</button></div></div>
|
||||
<div id="family-map-controls" class="family-map-controls"><div class="family-zoom-controls"><button id="family-zoom-out" class="quiet" aria-label="缩小家谱">−</button><output id="family-zoom-value" aria-live="polite">100%</output><button id="family-zoom-in" class="quiet" aria-label="放大家谱">+</button><button id="family-fit" class="quiet">查看全谱</button></div><button id="family-locate-self" class="quiet">◎ 定位本人</button><button id="family-fullscreen" class="quiet">全屏家谱</button></div>
|
||||
<div id="family-tree" class="family-map-viewport" tabindex="0" role="region" aria-label="家谱关系图,可拖动空白处或使用滚动条浏览"></div>
|
||||
<div id="family-people" class="family-people family-register" hidden></div>
|
||||
@@ -66,7 +66,7 @@
|
||||
<section id="page-users" class="page" hidden><div class="section-head"><h2>账号与人物关系 <small>ACCOUNTS</small></h2><button id="new-user">+ 新建账号</button></div><p class="description">亲属称呼用于描述人物关系。摄像头空间权限、家谱权限和管理员身份分别设置。无需为每一位家谱人物创建账号。</p><div id="user-list"></div></section>
|
||||
<footer><span>赵府智家 · 家庭空间</span><span id="version">独立部署 / 数据自有</span></footer>
|
||||
</main>
|
||||
<dialog id="auth-dialog"><form id="auth-form"><span class="eyebrow">WELCOME HOME</span><h2 id="auth-title">欢迎回来</h2><p id="auth-note">使用自己的账号,进入家庭空间。</p><label id="setup-code-label" hidden>初始化码<input name="code" autocomplete="off"></label><label>用户名<input name="username" value="admin" maxlength="32" required autocomplete="username"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" required autocomplete="current-password"></label><p class="form-error"></p><button type="submit" class="wide">进入系统</button></form></dialog>
|
||||
<dialog id="auth-dialog"><form id="auth-form"><span class="eyebrow">灯火可亲 · 归家有时</span><h2 id="auth-title">欢迎回来</h2><p id="auth-note">使用自己的账号,进入家庭空间。</p><label id="setup-code-label" hidden>初始化码<input name="code" autocomplete="off"></label><label>用户名<input name="username" value="admin" maxlength="32" required autocomplete="username"></label><label>密码<input name="password" type="password" minlength="8" maxlength="128" required autocomplete="current-password"></label><p class="form-error"></p><button type="submit" class="wide">进入系统</button></form></dialog>
|
||||
<dialog id="site-dialog"><form id="site-form"><div class="dialog-heading"><h2>空间档案</h2><button type="button" class="quiet" data-close="site-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>空间名称 *<input name="name" required maxlength="100" placeholder="例如:莱阳老家"></label><label>用途<select name="kind"><option value="home">家庭</option><option value="company">公司</option><option value="other">其他</option></select></label><label>所属家庭 / 单位<input name="owner" maxlength="100"></label><label>省 / 直辖市<input name="province" maxlength="100"></label><label>城市<input name="city" maxlength="100"></label><label>区 / 县<input name="district" maxlength="100"></label><label>街道 / 乡镇<input name="street" maxlength="100"></label><label>小区 / 园区 / 村<input name="community" maxlength="100"></label><label>楼栋<input name="building" maxlength="100" placeholder="例如:3 号楼"></label><label>单元<input name="unit" maxlength="100"></label><label>楼层<input name="floor" maxlength="100"></label><label>门牌号<input name="door" maxlength="100" placeholder="例如:502"></label><label class="full">详细地址<input name="address" maxlength="240"></label><label class="full">备注<input name="note" maxlength="240"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存空间</button></div></form></dialog>
|
||||
<dialog id="camera-dialog"><form id="camera-form"><div class="dialog-heading"><h2>镜头通道配置</h2><button type="button" class="quiet" data-close="camera-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label class="full">所属摄像头对象 *<select name="assetId" required></select></label><label>接入方式<select name="sourceKind"><option value="recorder">通过录像机</option><option value="direct">直接连接摄像头</option></select></label><label>关联录像机<select name="recorderId"><option value="">请选择</option></select></label><label>录像机通道编号<input name="channelNumber" maxlength="30" placeholder="按录像机实际通道填写"></label><label>录像存储设备<select name="archiveRecorderId"><option value="">尚未绑定</option></select></label><label>历史录像主要保存于<select name="archiveSource"><option value="recorder">录像机</option><option value="local">本机</option></select></label><label>镜头 / 通道名称 *<input name="name" required maxlength="120" placeholder="例如:大门口"></label><label>所属空间 *<select name="siteId" required></select></label><label>安装位置<input name="point" maxlength="120" placeholder="例如:院门外侧"></label><label>品牌 / 型号<input name="model" maxlength="120"></label><label>设备 IP *<input name="host" required placeholder="局域网 IPv4 地址"></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>设备用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" autocomplete="new-password" placeholder="编辑时留空,保留原密码"></label><div class="full"><button id="onvif-read" type="button" class="secondary">读取 ONVIF 镜头配置</button><p class="description">直接接入摄像头时,可读取设备实际提供的媒体配置。默认 ONVIF HTTP 端口为 80。</p><div id="onvif-result"></div></div><label class="full">常见码流路径<select id="path-preset"><option value="custom">手动填写</option><option value="tplink">TP-Link 单镜头</option><option value="hikvision">海康威视 / 兼容路径 · 通道 1</option><option value="dahua">大华 / 兼容路径 · 通道 1</option></select></label><label class="full">主码流路径 *<input name="mainPath" required maxlength="500" value="/stream1"></label><label class="full">子码流路径 *<input name="subPath" required maxlength="500" value="/stream2"></label><label class="check"><input name="enabled" type="checkbox" checked>启用连接</label><label class="check"><input name="record" type="checkbox">在小电脑额外保存录像(可选)</label></div><p class="description">账号和密码只保存在本机受限数据目录。RTSP 服务响应代表设备可达,实际码流接通后才显示在线。路径预设需要与设备型号、通道一致。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存设备</button></div></form></dialog>
|
||||
<dialog id="asset-dialog"><form id="asset-form"><div class="dialog-heading"><h2>摄像头对象</h2><button type="button" class="quiet" data-close="asset-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>对象名称 *<input name="name" required maxlength="120" placeholder="例如:院门双镜头摄像头"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>物理镜头数<input name="lensCount" type="number" value="2" min="1" max="16" required></label><label>安装位置<input name="point" maxlength="120"></label><label>备注<input name="note" maxlength="120"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存对象</button></div></form></dialog>
|
||||
@@ -76,7 +76,7 @@
|
||||
<dialog id="person-dialog"><form id="person-form"><div class="dialog-heading"><h2>家谱人物</h2><button type="button" class="quiet" data-close="person-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>姓名 / 称呼 *<input name="name" required maxlength="80" placeholder="姓名暂不知时可先填称呼"></label><label>别名 / 字 / 号<input name="alias" maxlength="120"></label><label>性别<select name="gender"><option value="unknown">待确认</option><option value="male">男</option><option value="female">女</option></select></label><label>在世情况<select name="lifeStatus"><option value="alive">在世</option><option value="deceased">不在世</option><option value="unknown">待确认</option></select></label><fieldset class="family-date" data-date-field="birthDate"><legend>出生日期</legend><input name="birthDate" type="hidden"><label>历法<select data-calendar aria-label="出生日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="出生日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><fieldset class="family-date" data-date-field="deathDate" hidden><legend>去世日期</legend><input name="deathDate" type="hidden"><label>历法<select data-calendar aria-label="去世日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="去世日期" type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite">日期不详可留空</output></fieldset><label class="full">籍贯 / 出生地<input name="birthplace" maxlength="160"></label><label class="full">生平简介<textarea name="biography" rows="4" maxlength="2000"></textarea></label><label class="full">备注 / 信息来源<textarea name="note" rows="3" maxlength="500" placeholder="例如:长辈口述、族谱记载、待考证信息"></textarea></label><label class="check"><input name="archived" type="checkbox">归档人物(保留关系,可恢复)</label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存人物</button></div></form></dialog>
|
||||
<dialog id="relation-dialog"><form id="relation-form"><div class="dialog-heading"><h2>连接人物关系</h2><button type="button" class="quiet" data-close="relation-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label>关系类型<select name="kind"><option value="parent">父母 → 子女</option><option value="spouse">配偶 ↔ 配偶</option></select></label><label>亲子属性<select name="lineage"><option value="unspecified">未注明</option><option value="biological">亲生</option><option value="adoptive">收养</option><option value="step">继亲</option></select></label><label>父母一方 / 配偶一方<select name="fromId" required></select></label><label>子女 / 另一位配偶<select name="toId" required></select></label><label class="full">关系备注<input name="note" maxlength="300"></label><label class="check"><input name="active" type="checkbox" checked>关系有效(取消后保留记录,可恢复)</label></div><p class="description">每条亲子关系连接一位父母与一位子女。录入另一位父母时,再建立一条关系。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存关系</button></div></form></dialog>
|
||||
<dialog id="event-dialog" aria-labelledby="event-editor-title"><form id="event-form"><div class="dialog-heading"><h2 id="event-editor-title">写一则家族记事</h2><button type="button" class="quiet" data-close="event-dialog">关闭</button></div><input name="id" type="hidden"><input name="revision" type="hidden"><div class="form-grid"><label class="full">记事标题 *<input name="title" required maxlength="160" placeholder="用一句话概括这件事"></label><fieldset class="family-date full" data-date-field="eventDate"><legend>事情发生的日期 *</legend><input name="eventDate" type="hidden"><label>历法<select data-calendar aria-label="记事日期历法"><option value="solar">公历</option><option value="lunar">农历</option></select></label><label>日期<input data-date-value aria-label="记事日期" required type="text" inputmode="numeric" placeholder="YYYY-MM-DD" maxlength="10"></label><label data-leap-label class="check" hidden><input data-date-leap type="checkbox">闰月</label><output data-date-hint aria-live="polite"></output></fieldset><fieldset class="full"><legend>关联人物(可多选)</legend><input id="event-person-search" type="search" placeholder="查找要关联的亲人" aria-label="查找关联人物"><div id="event-person-choices"></div><p class="description">未选择人物时记为全家记事。每位关联人物的档案都可以追溯到这条记事。</p></fieldset><label class="full">摘要<textarea name="summary" maxlength="400" rows="2" placeholder="简要说明这件事"></textarea></label><label class="full">详细记载<textarea name="body" maxlength="12000" rows="7" placeholder="记录经过、意义、长辈口述或资料出处。可分段书写。"></textarea></label><label>报道 / 资料来源<input name="sourceName" maxlength="160" placeholder="例如:报刊名称、个人博客"></label><label>原始报道链接<input name="sourceUrl" type="url" maxlength="2000" placeholder="https://…"></label><label class="check full"><input name="archived" type="checkbox">归档记事(保留内容,可恢复)</label></div><p class="description">正式发布后,有家谱查看权限的账号即可在时间线看到;草稿只对有编辑权限的人可见。</p><p class="form-error" role="status"></p><div class="dialog-footer event-editor-actions"><button type="submit" data-save-event value="draft" class="secondary">保存草稿</button><button type="submit" data-publish-event value="published">发布记事</button><button type="submit" data-withdraw-event value="draft" class="secondary" hidden>撤回为草稿</button></div></form></dialog>
|
||||
<dialog id="event-detail-dialog" aria-labelledby="event-detail-title"><div class="dialog-heading"><span class="eyebrow">FAMILY CHRONICLE</span><button type="button" class="quiet" data-close="event-detail-dialog">关闭</button></div><article id="event-detail-content"></article></dialog>
|
||||
<dialog id="event-detail-dialog" aria-labelledby="event-detail-title"><div class="dialog-heading"><span class="eyebrow">家事 · 家声</span><button type="button" class="quiet" data-close="event-detail-dialog">关闭</button></div><article id="event-detail-content"></article></dialog>
|
||||
<dialog id="history-dialog" aria-labelledby="history-title"><div class="dialog-heading"><h2 id="history-title">修改记录</h2><button type="button" class="quiet" data-close="history-dialog">关闭</button></div><p class="description">人物、关系与记事每次保存都会留存版本。仅有家谱编辑权限的账号可查阅和恢复。</p><p id="history-error" class="form-error" role="status"></p><div id="history-list"></div><button id="history-more" class="quiet" hidden>加载更早记录</button><article id="history-detail"></article></dialog>
|
||||
<dialog id="password-dialog" aria-labelledby="password-title"><form id="password-form"><div class="dialog-heading"><h2 id="password-title">修改我的密码</h2><button type="button" class="quiet" data-close="password-dialog">关闭</button></div><p id="password-account" class="description"></p><label>当前密码<input name="currentPassword" type="password" required maxlength="128" autocomplete="current-password"></label><label>新密码<input name="newPassword" type="password" required minlength="8" maxlength="128" autocomplete="new-password"></label><label>再次输入新密码<input name="confirmPassword" type="password" required minlength="8" maxlength="128" autocomplete="new-password"></label><p class="description">保存后,这个账号在所有设备上都需要用新密码重新登录。</p><p class="form-error" role="status"></p><div class="dialog-footer"><button type="submit">保存新密码</button></div></form></dialog>
|
||||
<dialog id="camera-settings-dialog" aria-labelledby="camera-settings-title"><div class="dialog-heading"><h2 id="camera-settings-title">摄像机参数</h2><button class="quiet" data-close="camera-settings-dialog">关闭</button></div><div class="section-head"><label>设置码流<select id="camera-settings-quality"><option value="main">主码流</option><option value="sub">子码流</option></select></label><button id="camera-settings-reload" class="quiet">重新读取</button></div><div id="camera-settings-info"></div><p id="camera-settings-status" class="form-error" role="status"></p><form id="camera-image-form"><h3>图像与日夜模式</h3><p class="description">作用于当前物理镜头,主、子码流共用这些图像参数。</p><div id="camera-image-fields" class="form-grid"></div><div class="dialog-footer"><button type="submit" disabled>保存图像参数</button></div></form><form id="camera-video-form"><h3>码流与清晰度</h3><p class="description">作用于上方选定的码流。保存后画面可能短暂重连,录像机也会使用调整后的码流。本页保留原有视频编码。</p><div id="camera-video-fields" class="form-grid"></div><div class="dialog-footer"><button type="submit" disabled>保存码流参数</button></div></form><details><summary>其他萤石功能的接入情况</summary><ul><li>云台方向微调与预置位:在实时画面的“云台”中使用,按设备能力启用。</li><li>实时声音与全屏:通过画面播放器控制。</li><li>双向对讲、智能追踪、声光告警、隐私遮蔽、设备翻转、云录像和消息推送:当前尚未接入,请使用萤石 App。</li><li>录像机原有录像:仍通过录像机或原厂 App 查看;本系统已支持本机额外录像的回放。</li></ul><p class="description">功能按设备实际开放能力逐项接入,型号与固件不同可能有差异。</p></details></dialog>
|
||||
|
||||
+2
-2
@@ -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)});}
|
||||
|
||||
Reference in New Issue
Block a user