dsh-intertidal 0.0.0-stage → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,72 @@
1
1
  {
2
2
  "name": "dsh-intertidal",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.4",
4
+ "description": "潮间(Intertidal):让 DSH 按设定间隔把屏幕交给一片此刻的海,到点自己退走。天光跟随本地太阳位置,可用自己的图片或视频作海面。不采集数据。",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "src/host.js",
8
+ "exports": {
9
+ ".": "./src/host.js",
10
+ "./client": "./dist/client.js",
11
+ "./cordis.patch.yml": "./cordis.patch.yml",
12
+ "./package.json": "./package.json"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "dist",
17
+ "cordis.patch.yml",
18
+ "screenshots.json",
19
+ "assets",
20
+ "README.md",
21
+ "README.en.md",
22
+ "LICENSE"
23
+ ],
24
+ "dsh": {
25
+ "manifestVersion": 1,
26
+ "bundle": {
27
+ "patch": "./cordis.patch.yml"
28
+ },
29
+ "client": {
30
+ "platform": "web",
31
+ "immediately": true,
32
+ "inject": [
33
+ "@deepseek-ai/dsh-client-runtime"
34
+ ]
35
+ }
36
+ },
37
+ "engines": {
38
+ "node": ">=20",
39
+ "dsh": ">=0.2.1-rc.5 <0.3.0"
40
+ },
41
+ "keywords": [
42
+ "dsh",
43
+ "deepseek-harness",
44
+ "dsh-plugin",
45
+ "dsh-market",
46
+ "intertidal",
47
+ "sea",
48
+ "ambience",
49
+ "screensaver",
50
+ "canvas"
51
+ ],
52
+ "scripts": {
53
+ "build": "node scripts/build.mjs",
54
+ "build:check": "node scripts/build.mjs --check",
55
+ "test": "node --test test/*.test.js",
56
+ "verify": "node scripts/build.mjs && node --test test/*.test.js",
57
+ "prepack": "node scripts/build.mjs"
58
+ },
59
+ "devDependencies": {
60
+ "esbuild": "^0.25.10"
61
+ },
62
+ "author": "winditer",
63
+ "repository": {
64
+ "type": "git",
65
+ "url": "git+https://github.com/winditer/dsh-intertidal.git"
66
+ },
67
+ "homepage": "https://github.com/winditer/dsh-intertidal#readme",
68
+ "bugs": {
69
+ "type": "url",
70
+ "url": "https://github.com/winditer/dsh-intertidal/issues"
71
+ }
72
+ }
@@ -0,0 +1,6 @@
1
+ [
2
+ "assets/screenshots/media-modes.png",
3
+ "assets/screenshots/procedural-overview.png",
4
+ "assets/screenshots/overlay-countdown.png",
5
+ "assets/screenshots/settings-narrow.png"
6
+ ]
package/src/cities.js ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * 潮间 · 内置城市表(≥ 40)与按系统时区猜城市
3
+ *
4
+ * 零依赖、零 IO。城市表只含「经纬度 + IANA 时区」,不含任何气候/水温等外部数据。
5
+ */
6
+
7
+ /** 内置城市表:id 用拉丁小写,zh/en 给 i18n 用 */
8
+ export const CITIES = Object.freeze(
9
+ [
10
+ { id: 'shanghai', zh: '上海', en: 'Shanghai', lat: 31.23, lon: 121.47, tz: 'Asia/Shanghai' },
11
+ { id: 'beijing', zh: '北京', en: 'Beijing', lat: 39.9, lon: 116.41, tz: 'Asia/Shanghai' },
12
+ { id: 'guangzhou', zh: '广州', en: 'Guangzhou', lat: 23.13, lon: 113.26, tz: 'Asia/Shanghai' },
13
+ { id: 'shenzhen', zh: '深圳', en: 'Shenzhen', lat: 22.54, lon: 114.06, tz: 'Asia/Shanghai' },
14
+ { id: 'chengdu', zh: '成都', en: 'Chengdu', lat: 30.57, lon: 104.07, tz: 'Asia/Shanghai' },
15
+ { id: 'hangzhou', zh: '杭州', en: 'Hangzhou', lat: 30.27, lon: 120.15, tz: 'Asia/Shanghai' },
16
+ { id: 'nanjing', zh: '南京', en: 'Nanjing', lat: 32.06, lon: 118.8, tz: 'Asia/Shanghai' },
17
+ { id: 'wuhan', zh: '武汉', en: 'Wuhan', lat: 30.59, lon: 114.31, tz: 'Asia/Shanghai' },
18
+ { id: 'xian', zh: '西安', en: "Xi'an", lat: 34.34, lon: 108.94, tz: 'Asia/Shanghai' },
19
+ { id: 'chongqing', zh: '重庆', en: 'Chongqing', lat: 29.56, lon: 106.55, tz: 'Asia/Shanghai' },
20
+ { id: 'qingdao', zh: '青岛', en: 'Qingdao', lat: 36.07, lon: 120.38, tz: 'Asia/Shanghai' },
21
+ { id: 'xiamen', zh: '厦门', en: 'Xiamen', lat: 24.48, lon: 118.09, tz: 'Asia/Shanghai' },
22
+ { id: 'harbin', zh: '哈尔滨', en: 'Harbin', lat: 45.8, lon: 126.53, tz: 'Asia/Shanghai' },
23
+ { id: 'kunming', zh: '昆明', en: 'Kunming', lat: 25.04, lon: 102.71, tz: 'Asia/Shanghai' },
24
+ { id: 'urumqi', zh: '乌鲁木齐', en: 'Urumqi', lat: 43.83, lon: 87.62, tz: 'Asia/Shanghai' },
25
+ { id: 'lhasa', zh: '拉萨', en: 'Lhasa', lat: 29.65, lon: 91.14, tz: 'Asia/Shanghai' },
26
+ { id: 'hongkong', zh: '香港', en: 'Hong Kong', lat: 22.32, lon: 114.17, tz: 'Asia/Hong_Kong' },
27
+ { id: 'macau', zh: '澳门', en: 'Macau', lat: 22.2, lon: 113.54, tz: 'Asia/Macau' },
28
+ { id: 'taipei', zh: '台北', en: 'Taipei', lat: 25.03, lon: 121.57, tz: 'Asia/Taipei' },
29
+ { id: 'tokyo', zh: '东京', en: 'Tokyo', lat: 35.68, lon: 139.69, tz: 'Asia/Tokyo' },
30
+ { id: 'osaka', zh: '大阪', en: 'Osaka', lat: 34.69, lon: 135.5, tz: 'Asia/Tokyo' },
31
+ { id: 'seoul', zh: '首尔', en: 'Seoul', lat: 37.57, lon: 126.98, tz: 'Asia/Seoul' },
32
+ { id: 'singapore', zh: '新加坡', en: 'Singapore', lat: 1.35, lon: 103.82, tz: 'Asia/Singapore' },
33
+ { id: 'bangkok', zh: '曼谷', en: 'Bangkok', lat: 13.76, lon: 100.5, tz: 'Asia/Bangkok' },
34
+ { id: 'hochiminh', zh: '胡志明市', en: 'Ho Chi Minh City', lat: 10.82, lon: 106.63, tz: 'Asia/Ho_Chi_Minh' },
35
+ { id: 'jakarta', zh: '雅加达', en: 'Jakarta', lat: -6.21, lon: 106.85, tz: 'Asia/Jakarta' },
36
+ { id: 'kualalumpur', zh: '吉隆坡', en: 'Kuala Lumpur', lat: 3.14, lon: 101.69, tz: 'Asia/Kuala_Lumpur' },
37
+ { id: 'manila', zh: '马尼拉', en: 'Manila', lat: 14.6, lon: 120.98, tz: 'Asia/Manila' },
38
+ { id: 'mumbai', zh: '孟买', en: 'Mumbai', lat: 19.08, lon: 72.88, tz: 'Asia/Kolkata' },
39
+ { id: 'delhi', zh: '新德里', en: 'New Delhi', lat: 28.61, lon: 77.21, tz: 'Asia/Kolkata' },
40
+ { id: 'dubai', zh: '迪拜', en: 'Dubai', lat: 25.2, lon: 55.27, tz: 'Asia/Dubai' },
41
+ { id: 'istanbul', zh: '伊斯坦布尔', en: 'Istanbul', lat: 41.01, lon: 28.98, tz: 'Europe/Istanbul' },
42
+ { id: 'moscow', zh: '莫斯科', en: 'Moscow', lat: 55.76, lon: 37.62, tz: 'Europe/Moscow' },
43
+ { id: 'athens', zh: '雅典', en: 'Athens', lat: 37.98, lon: 23.73, tz: 'Europe/Athens' },
44
+ { id: 'rome', zh: '罗马', en: 'Rome', lat: 41.9, lon: 12.5, tz: 'Europe/Rome' },
45
+ { id: 'berlin', zh: '柏林', en: 'Berlin', lat: 52.52, lon: 13.4, tz: 'Europe/Berlin' },
46
+ { id: 'paris', zh: '巴黎', en: 'Paris', lat: 48.86, lon: 2.35, tz: 'Europe/Paris' },
47
+ { id: 'madrid', zh: '马德里', en: 'Madrid', lat: 40.42, lon: -3.7, tz: 'Europe/Madrid' },
48
+ { id: 'lisbon', zh: '里斯本', en: 'Lisbon', lat: 38.72, lon: -9.14, tz: 'Europe/Lisbon' },
49
+ { id: 'amsterdam', zh: '阿姆斯特丹', en: 'Amsterdam', lat: 52.37, lon: 4.9, tz: 'Europe/Amsterdam' },
50
+ { id: 'london', zh: '伦敦', en: 'London', lat: 51.51, lon: -0.13, tz: 'Europe/London' },
51
+ { id: 'stockholm', zh: '斯德哥尔摩', en: 'Stockholm', lat: 59.33, lon: 18.07, tz: 'Europe/Stockholm' },
52
+ { id: 'oslo', zh: '奥斯陆', en: 'Oslo', lat: 59.91, lon: 10.75, tz: 'Europe/Oslo' },
53
+ { id: 'helsinki', zh: '赫尔辛基', en: 'Helsinki', lat: 60.17, lon: 24.94, tz: 'Europe/Helsinki' },
54
+ { id: 'reykjavik', zh: '雷克雅未克', en: 'Reykjavik', lat: 64.15, lon: -21.94, tz: 'Atlantic/Reykjavik' },
55
+ { id: 'tromso', zh: '特罗姆瑟', en: 'Tromso', lat: 69.65, lon: 18.96, tz: 'Europe/Oslo' },
56
+ { id: 'longyearbyen', zh: '朗伊尔城', en: 'Longyearbyen', lat: 78.22, lon: 15.65, tz: 'Arctic/Longyearbyen' },
57
+ { id: 'newyork', zh: '纽约', en: 'New York', lat: 40.71, lon: -74.01, tz: 'America/New_York' },
58
+ { id: 'losangeles', zh: '洛杉矶', en: 'Los Angeles', lat: 34.05, lon: -118.24, tz: 'America/Los_Angeles' },
59
+ { id: 'sanfrancisco', zh: '旧金山', en: 'San Francisco', lat: 37.77, lon: -122.42, tz: 'America/Los_Angeles' },
60
+ { id: 'seattle', zh: '西雅图', en: 'Seattle', lat: 47.61, lon: -122.33, tz: 'America/Los_Angeles' },
61
+ { id: 'chicago', zh: '芝加哥', en: 'Chicago', lat: 41.88, lon: -87.63, tz: 'America/Chicago' },
62
+ { id: 'denver', zh: '丹佛', en: 'Denver', lat: 39.74, lon: -104.99, tz: 'America/Denver' },
63
+ { id: 'anchorage', zh: '安克雷奇', en: 'Anchorage', lat: 61.22, lon: -149.9, tz: 'America/Anchorage' },
64
+ { id: 'toronto', zh: '多伦多', en: 'Toronto', lat: 43.65, lon: -79.38, tz: 'America/Toronto' },
65
+ { id: 'vancouver', zh: '温哥华', en: 'Vancouver', lat: 49.28, lon: -123.12, tz: 'America/Vancouver' },
66
+ { id: 'mexicocity', zh: '墨西哥城', en: 'Mexico City', lat: 19.43, lon: -99.13, tz: 'America/Mexico_City' },
67
+ { id: 'bogota', zh: '波哥大', en: 'Bogota', lat: 4.71, lon: -74.07, tz: 'America/Bogota' },
68
+ { id: 'lima', zh: '利马', en: 'Lima', lat: -12.05, lon: -77.04, tz: 'America/Lima' },
69
+ { id: 'saopaulo', zh: '圣保罗', en: 'Sao Paulo', lat: -23.55, lon: -46.63, tz: 'America/Sao_Paulo' },
70
+ { id: 'buenosaires', zh: '布宜诺斯艾利斯', en: 'Buenos Aires', lat: -34.6, lon: -58.38, tz: 'America/Argentina/Buenos_Aires' },
71
+ { id: 'santiago', zh: '圣地亚哥', en: 'Santiago', lat: -33.45, lon: -70.67, tz: 'America/Santiago' },
72
+ { id: 'cairo', zh: '开罗', en: 'Cairo', lat: 30.04, lon: 31.24, tz: 'Africa/Cairo' },
73
+ { id: 'nairobi', zh: '内罗毕', en: 'Nairobi', lat: -1.29, lon: 36.82, tz: 'Africa/Nairobi' },
74
+ { id: 'lagos', zh: '拉各斯', en: 'Lagos', lat: 6.52, lon: 3.38, tz: 'Africa/Lagos' },
75
+ { id: 'capetown', zh: '开普敦', en: 'Cape Town', lat: -33.92, lon: 18.42, tz: 'Africa/Johannesburg' },
76
+ { id: 'sydney', zh: '悉尼', en: 'Sydney', lat: -33.87, lon: 151.21, tz: 'Australia/Sydney' },
77
+ { id: 'melbourne', zh: '墨尔本', en: 'Melbourne', lat: -37.81, lon: 144.96, tz: 'Australia/Melbourne' },
78
+ { id: 'brisbane', zh: '布里斯班', en: 'Brisbane', lat: -27.47, lon: 153.03, tz: 'Australia/Brisbane' },
79
+ { id: 'perth', zh: '珀斯', en: 'Perth', lat: -31.95, lon: 115.86, tz: 'Australia/Perth' },
80
+ { id: 'auckland', zh: '奥克兰', en: 'Auckland', lat: -36.85, lon: 174.76, tz: 'Pacific/Auckland' },
81
+ { id: 'wellington', zh: '惠灵顿', en: 'Wellington', lat: -41.29, lon: 174.78, tz: 'Pacific/Auckland' },
82
+ { id: 'honolulu', zh: '檀香山', en: 'Honolulu', lat: 21.31, lon: -157.86, tz: 'Pacific/Honolulu' },
83
+ ].map(Object.freeze),
84
+ );
85
+
86
+ /** 兜底城市:猜不到就回上海(契约要求 guessCity 永不返回 undefined) */
87
+ const FALLBACK_CITY = CITIES[0];
88
+
89
+ const REGION_CITY = Object.freeze({
90
+ CN: 'shanghai', TW: 'taipei', HK: 'hongkong', MO: 'macau',
91
+ JP: 'tokyo', KR: 'seoul', SG: 'singapore', TH: 'bangkok',
92
+ VN: 'hochiminh', ID: 'jakarta', MY: 'kualalumpur', PH: 'manila',
93
+ IN: 'mumbai', AE: 'dubai', TR: 'istanbul', RU: 'moscow',
94
+ GR: 'athens', IT: 'rome', DE: 'berlin', FR: 'paris',
95
+ ES: 'madrid', PT: 'lisbon', NL: 'amsterdam', GB: 'london',
96
+ SE: 'stockholm', NO: 'oslo', FI: 'helsinki', IS: 'reykjavik',
97
+ US: 'newyork', CA: 'toronto', MX: 'mexicocity', BR: 'saopaulo',
98
+ AR: 'buenosaires', CL: 'santiago', CO: 'bogota', PE: 'lima',
99
+ EG: 'cairo', KE: 'nairobi', NG: 'lagos', ZA: 'capetown',
100
+ AU: 'sydney', NZ: 'auckland',
101
+ });
102
+
103
+ /** 球面距离(km) */
104
+ function distanceKm(lat1, lon1, lat2, lon2) {
105
+ const R = 6371;
106
+ const toRad = Math.PI / 180;
107
+ const dLat = (lat2 - lat1) * toRad;
108
+ const dLon = (lon2 - lon1) * toRad;
109
+ const a =
110
+ Math.sin(dLat / 2) ** 2 +
111
+ Math.cos(lat1 * toRad) * Math.cos(lat2 * toRad) * Math.sin(dLon / 2) ** 2;
112
+ return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
113
+ }
114
+
115
+ function nearestOf(list, lat, lon) {
116
+ let best = list[0];
117
+ let bestD = Infinity;
118
+ for (const city of list) {
119
+ const d = distanceKm(lat, lon, city.lat, city.lon);
120
+ if (d < bestD) {
121
+ bestD = d;
122
+ best = city;
123
+ }
124
+ }
125
+ return best;
126
+ }
127
+
128
+ /**
129
+ * 按 tz / locale / 坐标猜城市。永不返回 undefined(猜不到就返回上海)。
130
+ * 优先级:tz 精确命中 → 坐标最近 → locale 区域 → 上海。
131
+ */
132
+ export function guessCity(input) {
133
+ const opts = input && typeof input === 'object' ? input : {};
134
+ const { tz, locale } = opts;
135
+ const lat = Number(opts.lat);
136
+ const lon = Number(opts.lon);
137
+ const hasCoords =
138
+ Number.isFinite(lat) && Number.isFinite(lon) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180;
139
+
140
+ if (typeof tz === 'string' && tz) {
141
+ const sameTz = CITIES.filter((c) => c.tz === tz);
142
+ if (sameTz.length > 0) return hasCoords ? nearestOf(sameTz, lat, lon) : sameTz[0];
143
+ }
144
+
145
+ if (hasCoords) return nearestOf(CITIES, lat, lon);
146
+
147
+ if (typeof locale === 'string' && locale) {
148
+ const region = locale.split(/[-_]/)[1];
149
+ const id = region ? REGION_CITY[region.toUpperCase()] : undefined;
150
+ const hit = id ? findCity(id) : undefined;
151
+ if (hit) return hit;
152
+ }
153
+
154
+ return FALLBACK_CITY;
155
+ }
156
+
157
+ /** 按 id / 中文名 / 英文名查城市(忽略大小写与首尾空格) */
158
+ export function findCity(id) {
159
+ if (typeof id !== 'string') return undefined;
160
+ const key = id.trim().toLowerCase();
161
+ if (!key) return undefined;
162
+ return CITIES.find(
163
+ (c) => c.id === key || c.zh === id.trim() || c.en.toLowerCase() === key,
164
+ );
165
+ }
@@ -0,0 +1,140 @@
1
+ // 潮间 —— `/intertidal/*` 的浏览器侧封装。
2
+ //
3
+ // 三条要求(spec §8):
4
+ // 1. **永远不抛**:所有方法都 resolve,失败返回 `{ ok:false }`,由 runtime 决定降级;
5
+ // 2. 设置写入失败时用 `localStorage` 镜像,刷新后仍能用你刚改的值;
6
+ // 3. 超时必须有:宿主卡住时不能把 UI 拖住(4 秒)。
7
+
8
+ const BASE = '/intertidal'
9
+ const MIRROR_KEY = 'dsh-intertidal:settings'
10
+ const TIMEOUT_MS = 4000
11
+
12
+ /** 读镜像(坏 JSON 就当没有)。 */
13
+ export function readMirror(storage = safeStorage()) {
14
+ try {
15
+ const raw = storage?.getItem(MIRROR_KEY)
16
+ if (!raw) return null
17
+ const parsed = JSON.parse(raw)
18
+ return parsed && typeof parsed === 'object' ? parsed : null
19
+ } catch {
20
+ return null
21
+ }
22
+ }
23
+
24
+ export function writeMirror(settings, storage = safeStorage()) {
25
+ try {
26
+ storage?.setItem(MIRROR_KEY, JSON.stringify(settings))
27
+ } catch {
28
+ /* 隐私模式 / 配额满 → 忽略 */
29
+ }
30
+ }
31
+
32
+ function safeStorage() {
33
+ try {
34
+ return globalThis.localStorage ?? null
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ async function call(path, { method = 'GET', body, signal } = {}, fetchImpl) {
41
+ const doFetch = fetchImpl ?? globalThis.fetch
42
+ if (typeof doFetch !== 'function') return { ok: false, status: 0, error: 'no-fetch' }
43
+ const controller = typeof AbortController === 'function' ? new AbortController() : null
44
+ const timer = controller ? setTimeout(() => controller.abort(), TIMEOUT_MS) : null
45
+ try {
46
+ const init = { method, cache: 'no-store', credentials: 'same-origin', signal: controller?.signal ?? signal }
47
+ if (body !== undefined) {
48
+ init.headers = { 'content-type': 'application/json' }
49
+ init.body = JSON.stringify(body)
50
+ }
51
+ const res = await doFetch(`${BASE}${path}`, init)
52
+ if (!res.ok) return { ok: false, status: res.status, error: `http-${res.status}` }
53
+ const value = await res.json()
54
+ return { ok: true, value }
55
+ } catch (error) {
56
+ return { ok: false, status: 0, error: String(error?.name === 'AbortError' ? 'timeout' : (error?.message ?? error)) }
57
+ } finally {
58
+ if (timer) clearTimeout(timer)
59
+ }
60
+ }
61
+
62
+ /**
63
+ * 造一个 api。`onStatus` 用来把"宿主是否可用"报给 runtime(设置页据此显示一行告警)。
64
+ */
65
+ export function createApi({ fetchImpl, storage = safeStorage(), onStatus } = {}) {
66
+ let offline = false
67
+ const note = (ok) => {
68
+ if (offline === !ok) return
69
+ offline = !ok
70
+ try {
71
+ onStatus?.(offline)
72
+ } catch {
73
+ /* 回调自己抛了不该影响请求 */
74
+ }
75
+ }
76
+
77
+ return {
78
+ get offline() {
79
+ return offline
80
+ },
81
+
82
+ /** GET /state → `{ settings, rhythm, now }`;失败时退镜像 + null。 */
83
+ async state() {
84
+ const result = await call('/state', {}, fetchImpl)
85
+ note(result.ok)
86
+ if (result.ok) {
87
+ writeMirror(result.value?.settings, storage)
88
+ return result
89
+ }
90
+ const mirror = readMirror(storage)
91
+ return { ok: false, error: result.error, value: mirror ? { settings: mirror, rhythm: null, now: null } : null }
92
+ },
93
+
94
+ /** GET /now;带地点参数就按参数算,不带就用宿主设置。 */
95
+ async now(location) {
96
+ const query = location && Number.isFinite(location.lat) && Number.isFinite(location.lon)
97
+ ? `?lat=${encodeURIComponent(location.lat)}&lon=${encodeURIComponent(location.lon)}&tz=${encodeURIComponent(location.tz ?? '')}`
98
+ : ''
99
+ const result = await call(`/now${query}`, {}, fetchImpl)
100
+ note(result.ok)
101
+ return result
102
+ },
103
+
104
+ /** POST /settings:整份覆盖式提交(宿主做合并与钳制)。 */
105
+ async saveSettings(patch) {
106
+ const result = await call('/settings', { method: 'POST', body: patch }, fetchImpl)
107
+ note(result.ok)
108
+ if (result.ok) writeMirror(result.value, storage)
109
+ else {
110
+ const mirror = readMirror(storage) ?? {}
111
+ writeMirror({ ...mirror, ...patch }, storage)
112
+ }
113
+ return result
114
+ },
115
+
116
+ /** POST /rhythm:`shown` / `hidden` / `skip-today` / `manual-open`。 */
117
+ async rhythm(event) {
118
+ const result = await call('/rhythm', { method: 'POST', body: { event } }, fetchImpl)
119
+ note(result.ok)
120
+ return result
121
+ },
122
+
123
+ /** GET /media:这一次浮现用哪张素材(`fresh` 强制重新抽一张)。 */
124
+ async media(fresh = false) {
125
+ const result = await call(`/media${fresh ? '?fresh=1' : ''}`, {}, fetchImpl)
126
+ // 素材失败不影响看海(会退回程序化海),所以不改变 offline 判定
127
+ return result
128
+ },
129
+
130
+ /** POST /media/scan:设置页验证目录里有多少素材。 */
131
+ async scanMedia(dir) {
132
+ return call('/media/scan', { method: 'POST', body: { dir } }, fetchImpl)
133
+ },
134
+
135
+ /** POST /media/resolve:把一个网页/图片链接抓成本地缓存。 */
136
+ async resolveMedia(url) {
137
+ return call('/media/resolve', { method: 'POST', body: { url } }, fetchImpl)
138
+ },
139
+ }
140
+ }
@@ -0,0 +1,28 @@
1
+ // 潮间 —— 覆盖层里那些"给人看的小格式"。单独放一个纯 JS 文件,好让 node --test 直接 import
2
+ // (.jsx 在 node 里没法直接加载,测试只能绕)。
3
+
4
+ /** 剩余毫秒 → `m:ss`(不足 1 小时)或 `h:mm:ss`;向上取整,起点显示 3:00 而不是 2:59。 */
5
+ export function formatRemaining(ms) {
6
+ const total = Math.max(0, Math.ceil(Number(ms) / 1000) || 0)
7
+ const hours = Math.floor(total / 3600)
8
+ const minutes = Math.floor((total % 3600) / 60)
9
+ const seconds = total % 60
10
+ const pad = (value) => String(value).padStart(2, '0')
11
+ return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`
12
+ }
13
+
14
+ /** 某个地点的钟面时间(HH:MM);时区非法就退回本机时区。 */
15
+ export function formatClock(date, tz) {
16
+ try {
17
+ return new Intl.DateTimeFormat('en-GB', {
18
+ hour: '2-digit',
19
+ minute: '2-digit',
20
+ hour12: false,
21
+ timeZone: tz || undefined,
22
+ }).format(date)
23
+ } catch {
24
+ const h = String(date.getHours()).padStart(2, '0')
25
+ const m = String(date.getMinutes()).padStart(2, '0')
26
+ return `${h}:${m}`
27
+ }
28
+ }