From f941bf445431df400648c82e48062343cabc94a7 Mon Sep 17 00:00:00 2001 From: "DESKTOP-L8OUL9F\\gfl0034" <1003614916@qq.com> Date: Sun, 11 Oct 2026 21:32:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E8=A1=A5=E9=BD=90=E5=AE=98?= =?UTF-8?q?=E7=BD=91=E5=8D=8F=E8=AE=AE=E9=A1=B5=EF=BC=8C=E5=B9=B6=E6=8C=89?= =?UTF-8?q?=E5=8C=85=E5=90=8D=E6=8E=A7=E5=88=B6=E4=BC=9A=E5=91=98=E4=BB=B7?= =?UTF-8?q?=E6=A0=BC=E4=B8=8B=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 商店审核需要独立的隐私、用户协议和注销页;运营后台也要按应用包名启用或停用价格。 Co-authored-by: Cursor --- deploy/nginx/host/sparkmessenger.conf | 39 + deploy/popadmin/popadmin.js | 311 ++++++- docs/daily/2026-10-11-change-list.md | 182 ++++ fullstorynovel-web/assets/js/site.js | 1084 ++++++++++++++++++++++ fullstorynovel-web/delete/index.html | 88 ++ fullstorynovel-web/nginx-legal.snippet | 32 + fullstorynovel-web/privacy/index.html | 80 ++ fullstorynovel-web/public-index.html | 86 ++ fullstorynovel-web/terms/index.html | 79 ++ health-web/assets/icon.png | Bin 0 -> 45802 bytes health-web/assets/site.css | 210 +++++ health-web/delete/index.html | 70 ++ health-web/download/index.html | 54 ++ health-web/index.html | 90 ++ health-web/nginx/health.wyrs365.com.conf | 74 ++ health-web/privacy/index.html | 141 +++ health-web/terms/index.html | 118 +++ website/about/index.html | 74 ++ website/assets/legal.css | 229 +++++ website/assets/legal.js | 166 ++++ website/assets/styles.css | 80 +- website/delete/index.html | 59 ++ website/index.html | 8 + website/legal/legal.html | 135 +++ website/privacy/index.html | 3 + website/terms/index.html | 1 + 26 files changed, 3447 insertions(+), 46 deletions(-) create mode 100644 docs/daily/2026-10-11-change-list.md create mode 100644 fullstorynovel-web/assets/js/site.js create mode 100644 fullstorynovel-web/delete/index.html create mode 100644 fullstorynovel-web/nginx-legal.snippet create mode 100644 fullstorynovel-web/privacy/index.html create mode 100644 fullstorynovel-web/public-index.html create mode 100644 fullstorynovel-web/terms/index.html create mode 100644 health-web/assets/icon.png create mode 100644 health-web/assets/site.css create mode 100644 health-web/delete/index.html create mode 100644 health-web/download/index.html create mode 100644 health-web/index.html create mode 100644 health-web/nginx/health.wyrs365.com.conf create mode 100644 health-web/privacy/index.html create mode 100644 health-web/terms/index.html create mode 100644 website/about/index.html create mode 100644 website/assets/legal.css create mode 100644 website/assets/legal.js create mode 100644 website/delete/index.html create mode 100644 website/legal/legal.html create mode 100644 website/privacy/index.html create mode 100644 website/terms/index.html diff --git a/deploy/nginx/host/sparkmessenger.conf b/deploy/nginx/host/sparkmessenger.conf index 75a9074..d0c6131 100644 --- a/deploy/nginx/host/sparkmessenger.conf +++ b/deploy/nginx/host/sparkmessenger.conf @@ -50,6 +50,45 @@ server { try_files $uri =404; } + location = /legal.json { + default_type application/json; + charset utf-8; + try_files /legal.json =404; + } + + # Independent pages. Do not fall through to the marketing homepage. + location = /privacy { + try_files /privacy/index.html =404; + } + + location = /terms { + try_files /terms/index.html =404; + } + + location = /delete { + try_files /delete/index.html =404; + } + + location = /about { + try_files /about/index.html =404; + } + + location = /privacy/ { + return 301 /privacy$is_args$args; + } + + location = /terms/ { + return 301 /terms$is_args$args; + } + + location = /delete/ { + return 301 /delete$is_args$args; + } + + location = /about/ { + return 301 /about$is_args$args; + } + # Marketing site only — never expose API / OpenAPI / architecture docs here. location ~* ^/(docs|openapi|swagger|architecture)(/|\.|$) { return 404; diff --git a/deploy/popadmin/popadmin.js b/deploy/popadmin/popadmin.js index 134768a..0d16a88 100644 --- a/deploy/popadmin/popadmin.js +++ b/deploy/popadmin/popadmin.js @@ -2394,16 +2394,63 @@ const pages = { async prices() { - const [rows, apps] = await Promise.all([ - api(`/prices?appCode=${APP}`), - api('/apps').catch(() => []), - ]); - const all = Array.isArray(rows) ? rows : []; - const popApp = (Array.isArray(apps) ? apps : []).find((a) => a.code === APP) || {}; - const pkgOptions = Array.isArray(popApp.packageNames) ? popApp.packageNames.slice() : []; - // 常见火花包名兜底(注册表缺省时仍可手填) - ['com.jspp.pop', 'spark.wyrs.com', 'pop.wyrs.com'].forEach((p) => { - if (!pkgOptions.includes(p)) pkgOptions.push(p); + // 包名与客户端 X-Wyrs-Package 及工程 bundle 对齐;保存时写入对应 appCode,客户端按 appCode + 包名取价。 + // FullStoryNovel:下发包名 wf.wyrs.com(Android applicationId,iOS 请求同样发这个);iOS Bundle com.wyrs.fullstorynovel;注册表旧包名 com.wyrs.novelreader。 + // StepsWith:bundle 与下发包名均为 com.wyrs.runsteps。 + const PRICE_APPS = [ + { appCode: 'spark', name: 'Spark', packages: ['com.jspp.pop', 'spark.wyrs.com', 'pop.wyrs.com'] }, + { appCode: 'novel-reader', name: 'FullStoryNovel', packages: ['wf.wyrs.com', 'com.wyrs.fullstorynovel', 'com.wyrs.novelreader'] }, + { appCode: 'runsteps', name: 'StepsWith', packages: ['com.wyrs.runsteps'] }, + ]; + let apps = []; + const priceGroups = []; + try { + const fetched = await Promise.all([ + api('/apps').catch(() => []), + ...PRICE_APPS.map((d) => api('/prices?appCode=' + encodeURIComponent(d.appCode)).catch(() => [])), + ]); + apps = Array.isArray(fetched[0]) ? fetched[0] : []; + for (let i = 0; i < PRICE_APPS.length; i++) { + priceGroups.push(Array.isArray(fetched[i + 1]) ? fetched[i + 1] : []); + } + } catch (e) { + $('content').innerHTML = `
会员价格 API:${esc(e.message)}
`; + return; + } + const pkgMeta = new Map(); + PRICE_APPS.forEach((def) => { + const fromApi = apps.find((a) => a && a.code === def.appCode); + const names = def.packages.slice(); + const extra = fromApi && Array.isArray(fromApi.packageNames) ? fromApi.packageNames : []; + extra.forEach((p) => { + const v = String(p || '').trim(); + if (v && !names.includes(v)) names.push(v); + }); + names.forEach((p) => { + if (!pkgMeta.has(p)) pkgMeta.set(p, { appCode: def.appCode, name: def.name }); + }); + }); + const pkgOptions = [...pkgMeta.keys()]; + const pkgOptionLabel = (p) => { + const m = pkgMeta.get(p); + return m ? (p + ' · ' + m.name) : p; + }; + const appCodeForPackage = (pkg, row) => { + const m = pkgMeta.get(String(pkg || '').trim()); + if (m) return m.appCode; + if (row && row.appCode) return row.appCode; + return APP; + }; + const all = []; + const seenIds = new Set(); + priceGroups.forEach((group, i) => { + const code = PRICE_APPS[i].appCode; + group.forEach((p) => { + if (!p || p.id == null || seenIds.has(String(p.id))) return; + seenIds.add(String(p.id)); + if (!p.appCode) p.appCode = code; + all.push(p); + }); }); const savedFilter = (typeof window.__pricePkgFilter === 'string') ? window.__pricePkgFilter : ''; const list = (() => { @@ -2412,7 +2459,7 @@ const pages = { return all.filter((p) => (p.packageName || '') === savedFilter); })(); const CHANNELS = ['apple','google','paypal','pingpong','wechat','alipay','huawei','xiaomi','vivo','oppo','honor']; - const skuInputs = CHANNELS.map((k) => ``).join(''); + const skuInputs = CHANNELS.map((k) => ``).join(''); const billingLabel = (bt) => bt === 'subscription' ? '订阅' : (bt === 'one_time' ? '一次性' : (bt || '—')); const skuSummary = (m) => { if (!m || typeof m !== 'object') return '—'; @@ -2421,64 +2468,242 @@ const pages = { }; const pkgLabel = (p) => (p && String(p).trim()) ? p : '(默认·全包兜底)'; $('content').innerHTML = ` -
会员价格(火花)· 可按 applicationId / 包名 配置不同价格;包名为空表示该 App 默认兜底。客户端 GET /api/client/spark/prices?packageName=... 或 Header X-Wyrs-Package · 有精确包名价则用之,否则回退默认。
-
+ +
+
会员价格 · 按 applicationId / 包名 下发。Spark GET /api/client/spark/prices;FullStoryNovel GET /api/client/novel-reader/prices(下发包名 wf.wyrs.com,iOS Bundle com.wyrs.fullstorynovel);StepsWith GET /api/client/runsteps/prices(com.wyrs.runsteps)。Header X-Wyrs-Package · 有精确包名价则用之,否则回退该 App 的空包名默认。行首胶囊即 enabled:开=启用下发,关=停用不下发。
+
共 ${esc(list.length)} / ${esc(all.length)} 条
-
- - ${pkgOptions.map((p) => ``).join('')} + ${pkgOptions.map((p) => ``).join('')} - - - + + - - - - + + + +
+ + + + +
-
${skuInputs}
+
${skuInputs}
+
- + - ${list.length ? list.map((p) => ``).join('') : emptyRows(10, '暂无价格')} + ${list.length ? list.map((p) => { + const on = p.enabled !== false && p.enabled !== 0; + return ``; + }).join('') : emptyRows(10, '暂无价格')} -
ID包名名称类型价格原价天数渠道码启用
下发ID包名名称类型价格原价天数渠道码
${esc(p.id)}${esc(pkgLabel(p.packageName))}${esc(p.name)}${esc(billingLabel(p.billingType))}${esc(p.price)}${esc(p.originalPrice)}${esc(p.durationDays)}${esc(skuSummary(p.channelProductCodes))}${esc(p.enabled)}
${esc(p.id)}${esc(pkgLabel(p.packageName))}${esc(p.name)}${esc(billingLabel(p.billingType))}${esc(p.price)}${esc(p.originalPrice)}${esc(p.durationDays)}${esc(skuSummary(p.channelProductCodes))}
`; + +
+
`; + let editing = null; + const setEditMode = (on, id) => { + $('pfSave').textContent = on ? '保存修改' : '新增价格'; + $('pfCancel').style.display = on ? '' : 'none'; + $('pfEditHint').style.display = on ? '' : 'none'; + $('pfEditHint').textContent = on ? ('正在修改 #' + id) : ''; + }; + const clearPriceForm = () => { + editing = null; + $('pfPkg').value = ''; + $('pfPkgCustom').value = ''; + $('pfName').value = ''; + $('pfBilling').value = 'subscription'; + $('pfPrice').value = ''; + $('pfOrig').value = ''; + $('pfDays').value = ''; + $('pfEnabled').checked = true; + CHANNELS.forEach((k) => { const el = $('pfSku_' + k); if (el) el.value = ''; }); + setEditMode(false); + }; $('pfPkgFilter').onchange = () => { window.__pricePkgFilter = $('pfPkgFilter').value; pages.prices(); }; $('pfReload').onclick = () => pages.prices(); + $('pfCancel').onclick = clearPriceForm; $('pfSave').onclick = async () => { const channelProductCodes = {}; CHANNELS.forEach((k) => { const v = $('pfSku_' + k)?.value?.trim(); if (v) channelProductCodes[k] = v; }); const custom = ($('pfPkgCustom')?.value || '').trim(); const packageName = custom || ($('pfPkg')?.value || '').trim(); - await api('/prices', { - method: 'POST', - body: JSON.stringify({ - appCode: APP, - packageName, - name: $('pfName').value, - billingType: $('pfBilling').value, - price: $('pfPrice').value, - originalPrice: $('pfOrig').value, - durationDays: parseInt($('pfDays').value, 10) || 0, - channelProductCodes, - enabled: true, - }), - }); + const name = ($('pfName').value || '').trim(); + if (!name) return alert('请填写套餐名称'); + const priceNum = Number($('pfPrice').value); + if (!Number.isFinite(priceNum)) return alert('请填写有效价格'); + const origRaw = ($('pfOrig').value || '').trim(); + const origNum = origRaw === '' ? null : Number(origRaw); + if (origRaw !== '' && !Number.isFinite(origNum)) return alert('原价格式无效'); + const body = { + appCode: appCodeForPackage(packageName, editing), + packageName, + name, + billingType: $('pfBilling').value, + price: priceNum, + originalPrice: origNum, + durationDays: parseInt($('pfDays').value, 10) || 0, + channelProductCodes, + enabled: !!$('pfEnabled').checked, + }; + if (editing && editing.id != null) { + body.id = editing.id; + if (editing.productId) body.productId = editing.productId; + if (editing.createdAt) body.createdAt = editing.createdAt; + } + try { + await api('/prices', { method: 'POST', body: JSON.stringify(body) }); + } catch (e) { + alert((editing ? '修改失败:' : '新增失败:') + e.message); + return; + } pages.prices(); }; + $('content').querySelectorAll('[data-pedit]').forEach((btn) => { + btn.onclick = () => { + const row = all.find((p) => String(p.id) === String(btn.dataset.pedit)); + if (!row) return alert('价格不存在'); + editing = row; + const pkg = (row.packageName || '').trim(); + const known = [...$('pfPkg').options].some((o) => o.value === pkg); + if (pkg && known) { + $('pfPkg').value = pkg; + $('pfPkgCustom').value = ''; + } else { + $('pfPkg').value = ''; + $('pfPkgCustom').value = pkg; + } + $('pfName').value = row.name || ''; + $('pfBilling').value = row.billingType === 'one_time' ? 'one_time' : 'subscription'; + $('pfPrice').value = row.price ?? ''; + $('pfOrig').value = row.originalPrice ?? ''; + $('pfDays').value = row.durationDays ?? ''; + $('pfEnabled').checked = row.enabled !== false; + CHANNELS.forEach((k) => { + const el = $('pfSku_' + k); + if (el) el.value = (row.channelProductCodes && row.channelProductCodes[k]) || ''; + }); + setEditMode(true, row.id); + $('pfName').focus(); + }; + }); + $('content').querySelectorAll('[data-penable]').forEach((btn) => { + btn.onclick = async () => { + if (btn.disabled) return; + const row = all.find((p) => String(p.id) === String(btn.dataset.penable)); + if (!row) return alert('价格不存在'); + const prev = btn.getAttribute('aria-checked') === 'true'; + const next = !prev; + btn.disabled = true; + btn.setAttribute('aria-checked', next ? 'true' : 'false'); + btn.setAttribute('aria-label', (next ? '停用下发 ' : '启用下发 ') + (row.name || '')); + const body = { + id: row.id, + appCode: row.appCode || appCodeForPackage(row.packageName || '', row), + packageName: row.packageName || '', + name: row.name, + billingType: row.billingType || 'subscription', + price: row.price, + originalPrice: row.originalPrice, + durationDays: row.durationDays, + channelProductCodes: row.channelProductCodes || {}, + enabled: next, + }; + if (row.productId) body.productId = row.productId; + if (row.createdAt) body.createdAt = row.createdAt; + try { + await api('/prices', { method: 'POST', body: JSON.stringify(body) }); + } catch (e) { + btn.setAttribute('aria-checked', prev ? 'true' : 'false'); + btn.setAttribute('aria-label', (prev ? '停用下发 ' : '启用下发 ') + (row.name || '')); + btn.disabled = false; + alert('修改失败:' + e.message); + return; + } + pages.prices(); + }; + }); $('content').querySelectorAll('[data-pdel]').forEach((btn) => { btn.onclick = async () => { if (!confirm('确认删除?')) return; diff --git a/docs/daily/2026-10-11-change-list.md b/docs/daily/2026-10-11-change-list.md new file mode 100644 index 0000000..8c5f28d --- /dev/null +++ b/docs/daily/2026-10-11-change-list.md @@ -0,0 +1,182 @@ +# 每日变动 2026-10-11 + +## 16:06 — 官网导航增加协议、注销与关于入口 + +### 概要 + +- 官网顶栏在「能力」「下载」旁增加「隐私协议」「用户协议」「注销」「关于」,桌面并排,窄屏折行。 +- 隐私协议、用户协议链到已有页面;新建注销与关于页,子页面可回首页,当前页高亮。 + +### 变更范围 + +| 类型 | 说明 | +|------|------| +| 功能 | 官网主导航与两个子页面 | +| 配置 | 官网 nginx 为隐私、用户协议、注销、关于提供独立路径,避免落到首页 | + +### 主要文件 + +- `website/index.html` — 顶栏四个站内入口 +- `website/assets/styles.css` — 导航折行、当前页高亮、子页面版式 +- `website/about/index.html` — 关于页,复用官网介绍 +- `website/delete/index.html` — 注销说明(App 内邮箱验证码;找不到入口时走意见反馈) +- `website/legal/legal.html` — 协议页补同一导航、当前项与返回首页 +- `deploy/nginx/host/sparkmessenger.conf` — `/privacy`、`/terms` 改指向现有页面,并增加 `/delete`、`/about` + +### 验证 + +- `make test`: 未跑(只改官网静态页与 nginx,未改 Go) +- 本地静态服务打开首页,点过四个入口:隐私协议、用户协议、注销、关于均可打开;关于页「返回首页」回到首页。桌面 1280 导航同一行且不重叠;320 宽折行后仍可点。 + +### 关联 + +- 线上站点 sparkmessenger.wyrs365.com / sparkmsg.wyrs365.com + +## 16:07 — 会员价格补 FullStoryNovel / StepsWith 包名,行首胶囊开关下发 + +### 概要 + +- 会员价格页的包名下拉与筛选补上 FullStoryNovel、StepsWith。保存时按包名写入对应 appCode,客户端按 appCode + `X-Wyrs-Package` 命中精确价,否则回退该 App 的空包名默认。 +- 每条价格行最左侧改为胶囊开关,对应字段 `enabled`。点击即 `POST /api/admin/prices` 保存;开=启用下发,关=停用不下发。请求中禁止连点,失败回滚并 alert。 + +### 变更范围 + +| 类型 | 说明 | +|------|------| +| 功能 | popadmin 会员价格分包名与单条启用下发 | + +### 主要文件 + +- `deploy/popadmin/popadmin.js` — 包名选项与 appCode 映射;行首 `enabled` 胶囊开关 + +### 验证 + +- `make test`: 未跑(只改 popadmin 静态前端,未改 Go / SQL) +- 本地页面演练:下拉含 `wf.wyrs.com`、`com.wyrs.fullstorynovel`、`com.wyrs.runsteps`;保存分别落到 `novel-reader` / `runsteps`。胶囊开为绿、滑块在右,关为灰、滑块在左。点击关闭会 POST `enabled:false` 并在刷新后保持关闭;接口失败时开关回到原状态并提示「修改失败」。 + +### 关联 + +- 线上页 popadmin.wyrs365.com/popadmin/index.html「会员价格」 +- FullStoryNovel 客户端下发包名 `wf.wyrs.com`(iOS Bundle `com.wyrs.fullstorynovel`);StepsWith `com.wyrs.runsteps` + +## 19:50 — FullStoryNovel 官网页脚增加隐私政策、用户协议、注销 + +### 概要 + +- 在新加坡服务器的 fullstorynovel.com 首页页脚增加三个站内入口,并上线对应中文页面。 +- 本地留了一份不含密钥的副本。Spark 官网和 popadmin 公网已是新版本,这次没有覆盖。 + +### 变更范围 + +| 类型 | 说明 | +|------|------| +| 功能 | FullStoryNovel 官网页脚与三个子页面 | +| 配置 | 只增量改该站点 nginx:首页壳、`/privacy`、`/terms`、`/delete`、`site.js` | + +### 主要文件 + +- `fullstorynovel-web/public-index.html` — 页脚三个入口;点击时不走站内单页路由 +- `fullstorynovel-web/privacy/index.html` — 隐私政策 +- `fullstorynovel-web/terms/index.html` — 用户协议 +- `fullstorynovel-web/delete/index.html` — 注销说明 +- `fullstorynovel-web/assets/js/site.js` — 这三个路径改为整页打开 +- `fullstorynovel-web/nginx-legal.snippet` — 与线上增量配置对应的片段 + +### 验证 + +- `make test`: 未跑(只新增静态页副本,未改 Go、迁移、OpenAPI 或 i18n) +- 公网 curl:首页含三个入口;`/privacy`、`/terms`、`/delete` 均为 200 +- 浏览器点过三个入口并回到首页。桌面不重叠;390 宽页脚折行且不溢出 + +### 关联 + +- 服务器站点目录 `/opt/fullstorynovel/static-override` +- nginx `/etc/nginx/conf.d/fullstorynovel.conf`(已 `nginx -t` 并 reload,未重启整机) + +## 19:55 — FullStoryNovel 协议对齐 wyrs365,去掉管理入口 + +### 概要 + +- 隐私政策和用户协议改为与 wyrs365.com 上 FullStoryNovel(novel-reader)政策同一套条款结构和表述,产品名保持 Full Story Novel / FullStoryNovel。 +- 首页页脚不再放管理入口;隐私政策、用户协议、注销三个入口保留。注销页仍写 iOS 应用里的真实注销流程。 + +### 变更范围 + +| 类型 | 说明 | +|------|------| +| 文案 | `/privacy`、`/terms` 按 wyrs365 的 novel-reader 政策重写 | +| 页面 | 首页去掉管理入口;三个法律入口仍在页脚 | + +### 主要文件 + +- `fullstorynovel-web/privacy/index.html` — 软件隐私权政策 +- `fullstorynovel-web/terms/index.html` — 软件用户服务协议 +- `fullstorynovel-web/public-index.html` — 页脚无管理入口 +- 服务器 `/opt/fullstorynovel/static-override` 同步上述三个文件 + +### 验证 + +- `make test`: 未跑(只改静态页,未改 Go、迁移、OpenAPI 或 i18n) +- 公网:首页 200,无管理入口,含三个页脚链接;`/privacy`、`/terms`、`/delete` 均为 200。隐私政策含公司名、友盟披露和公开邮箱;用户协议含小说阅读服务条款。 + +### 关联 + +- 参考 https://www.wyrs365.com/ 、`/novel-reader-privacy`、`/novel-reader-agreement` +- 未改 nginx、未重启整机、未提交 git + +## 20:40 — Spark 协议正文对齐 wyrs365.com + +### 概要 + +- 官网 `/privacy`、`/terms` 仍进原有协议页。正文从四段短说明改为与 wyrs365.com 各 App 协议同一套条款:引言到联系方式共九部分,用户协议为导言到附则共八部分。 +- 产品名保持 Spark Messenger(火花)。公司名与公开邮箱沿用 wyrs365.com 已公开信息。简体、繁体为全文,其余语言显示英文全文。顶栏未改。 + +### 变更范围 + +| 类型 | 说明 | +|------|------| +| 文案 | 协议正文对齐 wyrs365.com 的条款范围 | + +### 主要文件 + +- `website/legal/legal.html` — 隐私政策与用户协议正文 + +### 验证 + +- `make test`: 未跑(只改官网静态协议页,未改 Go、迁移、OpenAPI 或 i18n) +- 本地打开隐私政策与英文用户协议,条款标题和联系邮箱可见。 +- 公网 `https://sparkmsg.wyrs365.com/legal/legal.html?doc=privacy` 含「Spark Messenger(火花)软件隐私权政策」和「九、如何联系我们」。 + +### 关联 + +- 参考 https://www.wyrs365.com/runsteps-privacy 、https://www.wyrs365.com/runsteps-agreement +- 未改 nginx、未动 FullStoryNovel、未提交 git + +## 21:35 — Sleep Heart 官网 health.wyrs365.com + +### 概要 + +- 阿里云 DNS 为 `health.wyrs365.com` 新增 A 记录到新加坡 `47.236.66.9`。 +- 新加坡机新增静态站与只含该域名的证书,首页及下载、注销、隐私协议、用户协议可访问。 + +### 变更范围 + +| 类型 | 说明 | +|------|------| +| 配置 | 仅该主机名的 DNS、Nginx 与证书 | +| 功能 | Sleep Heart 官网五页 | + +### 主要文件 + +- `health-web/` — 不含密钥的官网源码,已同步到 `/var/www/health.wyrs365.com` +- `health-web/nginx/health.wyrs365.com.conf` — 对应服务器 `/etc/nginx/conf.d/health-wyrs365.conf` + +### 验证 + +- `make test`: 未跑(只增加静态官网,未改 Go、迁移、OpenAPI 或 i18n) +- 直连公网 IP:首页、`/download`、`/delete`、`/privacy`、`/terms` 均为 200 +- 浏览器点过四个入口并回到首页;约 390px 宽无横向溢出 + +### 关联 + +- 未提交 git,未重启整机,未改安全组,未改其他站点文件 diff --git a/fullstorynovel-web/assets/js/site.js b/fullstorynovel-web/assets/js/site.js new file mode 100644 index 0000000..ea42b76 --- /dev/null +++ b/fullstorynovel-web/assets/js/site.js @@ -0,0 +1,1084 @@ +/* Full Story Novel — public site: i18n + theme + SPA */ +const LANG_KEY = "fsn_lang"; +const THEME_KEY = "fsn_theme"; +const DEFAULT_LANG = "en"; +const RTL_LANGS = new Set(["ar", "ur"]); + +const LANGS = [ + { code: "en", label: "English" }, + { code: "zh-CN", label: "简体中文" }, + { code: "zh-TW", label: "繁體中文" }, + { code: "hi", label: "हिन्दी" }, + { code: "es", label: "Español" }, + { code: "ar", label: "العربية" }, + { code: "bn", label: "বাংলা" }, + { code: "pt", label: "Português" }, + { code: "ru", label: "Русский" }, + { code: "ur", label: "اردو" }, + { code: "ja", label: "日本語" }, + { code: "ko", label: "한국어" }, + { code: "fr", label: "Français" }, + { code: "de", label: "Deutsch" }, + { code: "it", label: "Italiano" }, +]; + +const I18N = { + en: { + "nav.home": "Home", "nav.novels": "Novels", "nav.genres": "Genres", "nav.download": "Download App", + "search.placeholder": "Search novels...", "search.button": "Search", + "prefs.language": "Language", "prefs.theme": "Theme", "prefs.themeLight": "Light", "prefs.themeDark": "Dark", + "footer.tagline": "Serialized fantasy reading inspired by classic web-novel platforms.", + "footer.browse": "Browse", "footer.admin": "Admin", + "common.loading": "Loading…", "common.unknown": "Unknown", "common.any": "Any", + "common.viewAll": "View all", "common.chapters": "chapters", "common.views": "views", + "common.failed": "Failed to load", "common.previous": "Previous", "common.next": "Next", + "common.by": "by", "common.total": "total", "common.series": "series", + "home.featured": "Featured Series", "home.readNow": "Read Now", "home.browse": "Browse Library", + "home.welcome": "Welcome to Full Story Novel", "home.emptyHint": "Add novels from the admin panel to get started.", + "home.popularGenres": "Popular Genres", "home.noGenres": "No genres yet", + "home.popular": "Popular This Week", "home.new": "New Releases", "home.updated": "Recently Updated", + "home.nothing": "Nothing here yet.", + "novels.title": "Novels", "novels.search": "Search", "novels.genre": "Category", + "novels.tag": "Tag", "novels.allTags": "All tags", + "novels.status": "Status", "novels.language": "Language", "novels.sort": "Sort", + "novels.placeholder": "Title or author", "novels.empty": "No novels matched.", + "status.ONGOING": "Ongoing", "status.COMPLETED": "Completed", "status.HIATUS": "Hiatus", + "lang.ENGLISH": "English", "lang.CHINESE": "Chinese", "lang.KOREAN": "Korean", + "sort.popular": "Popular", "sort.rating": "Rating", "sort.newest": "New", + "sort.updated": "Updated", "sort.name": "Name", "sort.chapters": "Chapters", + "genres.title": "Genres", "genres.explore": "Explore this genre.", + "genres.tags": "Tags", + "novel.start": "Start Reading", "novel.noChapters": "No chapters yet", + "novel.back": "Back to Library", "novel.chapters": "Chapters", "novel.chapter": "Chapter", + "novel.nonePublished": "No chapters published.", + "read.prev": "← Previous", "read.next": "Next →", "read.chapters": "Chapters", + "download.title": "Read anywhere", + "download.lead": "Offline bookshelf, PDF/TXT import, and cloud picks — crafted for Android.", + "download.android": "Get Android APK", + "download.resolving": "Preparing the latest build…", + "download.ready": "Build ready — tap to install.", + "download.unavailable": "Package temporarily unavailable. Please try again shortly.", + "download.hint1": "Android only for now. Allow installs from this browser if prompted.", + "download.hint2": "Package name: wf.wyrs.com", + "download.hint3": "Signed release build mirrored on this site for a direct install.", + "download.feature1": "Offline bookshelf", + "download.feature2": "PDF / TXT import", + "download.feature3": "Cloud recommendations", + "download.platform": "Android", + }, + "zh-CN": { + "nav.home": "首页", "nav.novels": "书库", "nav.genres": "类型", "nav.download": "App下载", + "search.placeholder": "搜索小说…", "search.button": "搜索", + "prefs.language": "语言", "prefs.theme": "主题", "prefs.themeLight": "日间", "prefs.themeDark": "暗黑", + "footer.tagline": "连载奇幻阅读,致敬经典网文平台。", + "footer.browse": "浏览", "footer.admin": "管理", + "common.loading": "加载中…", "common.unknown": "未知", "common.any": "全部", + "common.viewAll": "查看全部", "common.chapters": "章", "common.views": "阅读", + "common.failed": "加载失败", "common.previous": "上一页", "common.next": "下一页", + "common.by": "作者", "common.total": "共", "common.series": "部作品", + "home.featured": "精选推荐", "home.readNow": "立即阅读", "home.browse": "浏览书库", + "home.welcome": "欢迎来到 Full Story Novel", "home.emptyHint": "请先在管理后台添加小说。", + "home.popularGenres": "热门类型", "home.noGenres": "暂无类型", + "home.popular": "本周热门", "home.new": "新书上架", "home.updated": "最近更新", + "home.nothing": "暂无内容。", + "novels.title": "书库", "novels.search": "搜索", "novels.genre": "一级分类", + "novels.tag": "二级标签", "novels.allTags": "全部标签", + "novels.status": "状态", "novels.language": "语言", "novels.sort": "排序", + "novels.placeholder": "书名或作者", "novels.empty": "没有匹配的小说。", + "status.ONGOING": "连载中", "status.COMPLETED": "已完结", "status.HIATUS": "暂停", + "lang.ENGLISH": "英语", "lang.CHINESE": "中文", "lang.KOREAN": "韩语", + "sort.popular": "热门", "sort.rating": "评分", "sort.newest": "最新", + "sort.updated": "更新", "sort.name": "名称", "sort.chapters": "章节数", + "genres.title": "类型", "genres.explore": "探索该类型。", + "genres.tags": "二级标签", + "novel.start": "开始阅读", "novel.noChapters": "暂无章节", + "novel.back": "返回书库", "novel.chapters": "章节列表", "novel.chapter": "第", + "novel.nonePublished": "暂无已发布章节。", + "read.prev": "← 上一章", "read.next": "下一章 →", "read.chapters": "目录", + "download.title": "随时开读", + "download.lead": "本地书架、PDF/TXT 导入与云端推荐 — 专为 Android 打造。", + "download.android": "获取 Android 安装包", + "download.resolving": "正在准备最新构建…", + "download.ready": "构建已就绪,点击即可安装。", + "download.unavailable": "安装包暂不可用,请稍后再试。", + "download.hint1": "目前仅提供 Android。若系统提示,请允许从此浏览器安装。", + "download.hint2": "包名:wf.wyrs.com", + "download.hint3": "本站提供已签名正式版,可直接安装。", + "download.feature1": "离线书架", + "download.feature2": "PDF / TXT 导入", + "download.feature3": "云端推荐", + "download.platform": "Android", + }, + "zh-TW": { + "nav.home": "首頁", "nav.novels": "書庫", "nav.genres": "類型", "nav.download": "App下載", + "search.placeholder": "搜尋小說…", "search.button": "搜尋", + "prefs.language": "語言", "prefs.theme": "主題", "prefs.themeLight": "日間", "prefs.themeDark": "暗黑", + "footer.tagline": "連載奇幻閱讀,致敬經典網文平台。", + "footer.browse": "瀏覽", "footer.admin": "管理", + "common.loading": "載入中…", "common.unknown": "未知", "common.any": "全部", + "common.viewAll": "查看全部", "common.chapters": "章", "common.views": "閱讀", + "common.failed": "載入失敗", "common.previous": "上一頁", "common.next": "下一頁", + "common.by": "作者", "common.total": "共", "common.series": "部作品", + "home.featured": "精選推薦", "home.readNow": "立即閱讀", "home.browse": "瀏覽書庫", + "home.welcome": "歡迎來到 Full Story Novel", "home.emptyHint": "請先在管理後台新增小說。", + "home.popularGenres": "熱門類型", "home.noGenres": "暫無類型", + "home.popular": "本週熱門", "home.new": "新書上架", "home.updated": "最近更新", + "home.nothing": "暫無內容。", + "novels.title": "書庫", "novels.search": "搜尋", "novels.genre": "一級分類", + "novels.tag": "二級標籤", "novels.allTags": "全部標籤", + "novels.status": "狀態", "novels.language": "語言", "novels.sort": "排序", + "novels.placeholder": "書名或作者", "novels.empty": "沒有符合的小說。", + "status.ONGOING": "連載中", "status.COMPLETED": "已完結", "status.HIATUS": "暫停", + "lang.ENGLISH": "英語", "lang.CHINESE": "中文", "lang.KOREAN": "韓語", + "sort.popular": "熱門", "sort.rating": "評分", "sort.newest": "最新", + "sort.updated": "更新", "sort.name": "名稱", "sort.chapters": "章節數", + "genres.title": "類型", "genres.explore": "探索此類型。", + "genres.tags": "二級標籤", + "novel.start": "開始閱讀", "novel.noChapters": "暫無章節", + "novel.back": "返回書庫", "novel.chapters": "章節列表", "novel.chapter": "第", + "novel.nonePublished": "暫無已發佈章節。", + "read.prev": "← 上一章", "read.next": "下一章 →", "read.chapters": "目錄", + "download.title": "隨時開讀", + "download.lead": "本地書架、PDF/TXT 匯入與雲端推薦 — 專為 Android 打造。", + "download.android": "取得 Android 安裝包", + "download.resolving": "正在準備最新建置…", + "download.ready": "建置已就緒,點擊即可安裝。", + "download.unavailable": "安裝包暫不可用,請稍後再試。", + "download.hint1": "目前僅提供 Android。若系統提示,請允許從此瀏覽器安裝。", + "download.hint2": "包名:wf.wyrs.com", + "download.hint3": "本站提供已簽名正式版,可直接安裝。", + "download.feature1": "離線書架", + "download.feature2": "PDF / TXT 匯入", + "download.feature3": "雲端推薦", + "download.platform": "Android", + }, + hi: { + "nav.home": "होम", "nav.novels": "उपन्यास", "nav.genres": "विधाएँ", + "search.placeholder": "उपन्यास खोजें...", "search.button": "खोजें", + "prefs.language": "भाषा", "prefs.theme": "थीम", "prefs.themeLight": "दिन", "prefs.themeDark": "रात", + "footer.tagline": "क्लासिक वेब-उपन्यास शैली में धारावाहिक फंतासी पठन।", + "footer.browse": "ब्राउज़", "footer.admin": "एडमिन", + "common.loading": "लोड हो रहा है…", "common.unknown": "अज्ञात", "common.any": "कोई भी", + "common.viewAll": "सभी देखें", "common.chapters": "अध्याय", "common.views": "दृश्य", + "common.failed": "लोड विफल", "common.previous": "पिछला", "common.next": "अगला", + "common.by": "द्वारा", "common.total": "कुल", "common.series": "श्रृंखला", + "home.featured": "विशेष श्रृंखला", "home.readNow": "अभी पढ़ें", "home.browse": "लाइब्रेरी देखें", + "home.welcome": "Full Story Novel में आपका स्वागत है", "home.emptyHint": "शुरू करने के लिए एडमिन से उपन्यास जोड़ें।", + "home.popularGenres": "लोकप्रिय विधाएँ", "home.noGenres": "अभी कोई विधा नहीं", + "home.popular": "इस सप्ताह लोकप्रिय", "home.new": "नई रिलीज़", "home.updated": "हाल ही में अपडेट", + "home.nothing": "अभी कुछ नहीं।", + "novels.title": "उपन्यास", "novels.search": "खोज", "novels.genre": "विधा", + "novels.status": "स्थिति", "novels.language": "भाषा", "novels.sort": "क्रम", + "novels.placeholder": "शीर्षक या लेखक", "novels.empty": "कोई उपन्यास नहीं मिला।", + "status.ONGOING": "चालू", "status.COMPLETED": "पूर्ण", "status.HIATUS": "विराम", + "lang.ENGLISH": "अंग्रेज़ी", "lang.CHINESE": "चीनी", "lang.KOREAN": "कोरियाई", + "sort.popular": "लोकप्रिय", "sort.rating": "रेटिंग", "sort.newest": "नया", + "sort.updated": "अपडेट", "sort.name": "नाम", "sort.chapters": "अध्याय", + "genres.title": "विधाएँ", "genres.explore": "इस विधा को देखें।", + "novel.start": "पढ़ना शुरू करें", "novel.noChapters": "अभी अध्याय नहीं", + "novel.back": "लाइब्रेरी पर वापस", "novel.chapters": "अध्याय", "novel.chapter": "अध्याय", + "novel.nonePublished": "कोई अध्याय प्रकाशित नहीं।", + "read.prev": "← पिछला", "read.next": "अगला →", "read.chapters": "अध्याय", + }, + es: { + "nav.home": "Inicio", "nav.novels": "Novelas", "nav.genres": "Géneros", + "search.placeholder": "Buscar novelas...", "search.button": "Buscar", + "prefs.language": "Idioma", "prefs.theme": "Tema", "prefs.themeLight": "Claro", "prefs.themeDark": "Oscuro", + "footer.tagline": "Lectura fantástica por entregas inspirada en novelas web clásicas.", + "footer.browse": "Explorar", "footer.admin": "Admin", + "common.loading": "Cargando…", "common.unknown": "Desconocido", "common.any": "Cualquiera", + "common.viewAll": "Ver todo", "common.chapters": "capítulos", "common.views": "vistas", + "common.failed": "Error al cargar", "common.previous": "Anterior", "common.next": "Siguiente", + "common.by": "por", "common.total": "total", "common.series": "series", + "home.featured": "Serie destacada", "home.readNow": "Leer ahora", "home.browse": "Explorar biblioteca", + "home.welcome": "Bienvenido a Full Story Novel", "home.emptyHint": "Añade novelas desde el panel de administración.", + "home.popularGenres": "Géneros populares", "home.noGenres": "Sin géneros aún", + "home.popular": "Popular esta semana", "home.new": "Novedades", "home.updated": "Actualizado recientemente", + "home.nothing": "Nada aquí todavía.", + "novels.title": "Novelas", "novels.search": "Buscar", "novels.genre": "Género", + "novels.status": "Estado", "novels.language": "Idioma", "novels.sort": "Orden", + "novels.placeholder": "Título o autor", "novels.empty": "Ninguna novela coincide.", + "status.ONGOING": "En curso", "status.COMPLETED": "Completada", "status.HIATUS": "Pausa", + "lang.ENGLISH": "Inglés", "lang.CHINESE": "Chino", "lang.KOREAN": "Coreano", + "sort.popular": "Popular", "sort.rating": "Valoración", "sort.newest": "Nuevo", + "sort.updated": "Actualizado", "sort.name": "Nombre", "sort.chapters": "Capítulos", + "genres.title": "Géneros", "genres.explore": "Explora este género.", + "novel.start": "Empezar a leer", "novel.noChapters": "Sin capítulos aún", + "novel.back": "Volver a la biblioteca", "novel.chapters": "Capítulos", "novel.chapter": "Capítulo", + "novel.nonePublished": "No hay capítulos publicados.", + "read.prev": "← Anterior", "read.next": "Siguiente →", "read.chapters": "Capítulos", + }, + ar: { + "nav.home": "الرئيسية", "nav.novels": "الروايات", "nav.genres": "الأنواع", + "search.placeholder": "ابحث عن روايات...", "search.button": "بحث", + "prefs.language": "اللغة", "prefs.theme": "المظهر", "prefs.themeLight": "نهاري", "prefs.themeDark": "داكن", + "footer.tagline": "قراءة خيالية متسلسلة مستوحاة من منصات الروايات الكلاسيكية.", + "footer.browse": "تصفح", "footer.admin": "الإدارة", + "common.loading": "جارٍ التحميل…", "common.unknown": "غير معروف", "common.any": "الكل", + "common.viewAll": "عرض الكل", "common.chapters": "فصول", "common.views": "مشاهدات", + "common.failed": "فشل التحميل", "common.previous": "السابق", "common.next": "التالي", + "common.by": "بقلم", "common.total": "الإجمالي", "common.series": "سلسلة", + "home.featured": "سلسلة مميزة", "home.readNow": "اقرأ الآن", "home.browse": "تصفح المكتبة", + "home.welcome": "مرحبًا بك في Full Story Novel", "home.emptyHint": "أضف روايات من لوحة الإدارة للبدء.", + "home.popularGenres": "أنواع شائعة", "home.noGenres": "لا أنواع بعد", + "home.popular": "الأكثر شعبية هذا الأسبوع", "home.new": "إصدارات جديدة", "home.updated": "حديث التحديث", + "home.nothing": "لا شيء هنا بعد.", + "novels.title": "الروايات", "novels.search": "بحث", "novels.genre": "النوع", + "novels.status": "الحالة", "novels.language": "اللغة", "novels.sort": "ترتيب", + "novels.placeholder": "العنوان أو المؤلف", "novels.empty": "لا روايات مطابقة.", + "status.ONGOING": "مستمرة", "status.COMPLETED": "مكتملة", "status.HIATUS": "متوقفة", + "lang.ENGLISH": "الإنجليزية", "lang.CHINESE": "الصينية", "lang.KOREAN": "الكورية", + "sort.popular": "شائع", "sort.rating": "التقييم", "sort.newest": "الأحدث", + "sort.updated": "محدث", "sort.name": "الاسم", "sort.chapters": "الفصول", + "genres.title": "الأنواع", "genres.explore": "استكشف هذا النوع.", + "novel.start": "ابدأ القراءة", "novel.noChapters": "لا فصول بعد", + "novel.back": "العودة للمكتبة", "novel.chapters": "الفصول", "novel.chapter": "فصل", + "novel.nonePublished": "لا فصول منشورة.", + "read.prev": "→ السابق", "read.next": "التالي ←", "read.chapters": "الفصول", + }, + bn: { + "nav.home": "হোম", "nav.novels": "উপন্যাস", "nav.genres": "ধরন", + "search.placeholder": "উপন্যাস খুঁজুন...", "search.button": "অনুসন্ধান", + "prefs.language": "ভাষা", "prefs.theme": "থিম", "prefs.themeLight": "দিন", "prefs.themeDark": "রাত", + "footer.tagline": "ক্লাসিক ওয়েব-উপন্যাস প্ল্যাটফর্ম থেকে অনুপ্রাণিত ধারাবাহিক ফ্যান্টাসি পাঠ।", + "footer.browse": "ব্রাউজ", "footer.admin": "অ্যাডমিন", + "common.loading": "লোড হচ্ছে…", "common.unknown": "অজানা", "common.any": "যেকোনো", + "common.viewAll": "সব দেখুন", "common.chapters": "অধ্যায়", "common.views": "দেখা", + "common.failed": "লোড ব্যর্থ", "common.previous": "পূর্ববর্তী", "common.next": "পরবর্তী", + "common.by": "লেখক", "common.total": "মোট", "common.series": "সিরিজ", + "home.featured": "বিশেষ সিরিজ", "home.readNow": "এখনই পড়ুন", "home.browse": "লাইব্রেরি দেখুন", + "home.welcome": "Full Story Novel-এ স্বাগতম", "home.emptyHint": "শুরু করতে অ্যাডমিন থেকে উপন্যাস যোগ করুন।", + "home.popularGenres": "জনপ্রিয় ধরন", "home.noGenres": "এখনো কোনো ধরন নেই", + "home.popular": "এই সপ্তাহের জনপ্রিয়", "home.new": "নতুন প্রকাশ", "home.updated": "সম্প্রতি আপডেট", + "home.nothing": "এখনো কিছু নেই।", + "novels.title": "উপন্যাস", "novels.search": "অনুসন্ধান", "novels.genre": "ধরন", + "novels.status": "অবস্থা", "novels.language": "ভাষা", "novels.sort": "সাজানো", + "novels.placeholder": "শিরোনাম বা লেখক", "novels.empty": "কোনো উপন্যাস মেলেনি।", + "status.ONGOING": "চলমান", "status.COMPLETED": "সম্পূর্ণ", "status.HIATUS": "বিরতি", + "lang.ENGLISH": "ইংরেজি", "lang.CHINESE": "চীনা", "lang.KOREAN": "কোরিয়ান", + "sort.popular": "জনপ্রিয়", "sort.rating": "রেটিং", "sort.newest": "নতুন", + "sort.updated": "আপডেট", "sort.name": "নাম", "sort.chapters": "অধ্যায়", + "genres.title": "ধরন", "genres.explore": "এই ধরন অন্বেষণ করুন।", + "novel.start": "পড়া শুরু করুন", "novel.noChapters": "এখনো অধ্যায় নেই", + "novel.back": "লাইব্রেরিতে ফিরুন", "novel.chapters": "অধ্যায়", "novel.chapter": "অধ্যায়", + "novel.nonePublished": "কোনো অধ্যায় প্রকাশিত হয়নি।", + "read.prev": "← পূর্ববর্তী", "read.next": "পরবর্তী →", "read.chapters": "অধ্যায়", + }, + pt: { + "nav.home": "Início", "nav.novels": "Romances", "nav.genres": "Gêneros", + "search.placeholder": "Pesquisar romances...", "search.button": "Pesquisar", + "prefs.language": "Idioma", "prefs.theme": "Tema", "prefs.themeLight": "Claro", "prefs.themeDark": "Escuro", + "footer.tagline": "Leitura fantástica em série inspirada em plataformas clássicas de web novels.", + "footer.browse": "Explorar", "footer.admin": "Admin", + "common.loading": "Carregando…", "common.unknown": "Desconhecido", "common.any": "Qualquer", + "common.viewAll": "Ver tudo", "common.chapters": "capítulos", "common.views": "visualizações", + "common.failed": "Falha ao carregar", "common.previous": "Anterior", "common.next": "Próximo", + "common.by": "por", "common.total": "total", "common.series": "séries", + "home.featured": "Série em destaque", "home.readNow": "Ler agora", "home.browse": "Explorar biblioteca", + "home.welcome": "Bem-vindo ao Full Story Novel", "home.emptyHint": "Adicione romances no painel admin para começar.", + "home.popularGenres": "Gêneros populares", "home.noGenres": "Sem gêneros ainda", + "home.popular": "Popular esta semana", "home.new": "Lançamentos", "home.updated": "Atualizados recentemente", + "home.nothing": "Nada aqui ainda.", + "novels.title": "Romances", "novels.search": "Pesquisar", "novels.genre": "Gênero", + "novels.status": "Status", "novels.language": "Idioma", "novels.sort": "Ordenar", + "novels.placeholder": "Título ou autor", "novels.empty": "Nenhum romance encontrado.", + "status.ONGOING": "Em andamento", "status.COMPLETED": "Concluído", "status.HIATUS": "Pausa", + "lang.ENGLISH": "Inglês", "lang.CHINESE": "Chinês", "lang.KOREAN": "Coreano", + "sort.popular": "Popular", "sort.rating": "Avaliação", "sort.newest": "Novo", + "sort.updated": "Atualizado", "sort.name": "Nome", "sort.chapters": "Capítulos", + "genres.title": "Gêneros", "genres.explore": "Explore este gênero.", + "novel.start": "Começar a ler", "novel.noChapters": "Sem capítulos ainda", + "novel.back": "Voltar à biblioteca", "novel.chapters": "Capítulos", "novel.chapter": "Capítulo", + "novel.nonePublished": "Nenhum capítulo publicado.", + "read.prev": "← Anterior", "read.next": "Próximo →", "read.chapters": "Capítulos", + }, + ru: { + "nav.home": "Главная", "nav.novels": "Новеллы", "nav.genres": "Жанры", + "search.placeholder": "Поиск новелл...", "search.button": "Найти", + "prefs.language": "Язык", "prefs.theme": "Тема", "prefs.themeLight": "Светлая", "prefs.themeDark": "Тёмная", + "footer.tagline": "Серийное фэнтези в духе классических веб-новелл.", + "footer.browse": "Каталог", "footer.admin": "Админ", + "common.loading": "Загрузка…", "common.unknown": "Неизвестно", "common.any": "Любой", + "common.viewAll": "Смотреть все", "common.chapters": "глав", "common.views": "просмотров", + "common.failed": "Ошибка загрузки", "common.previous": "Назад", "common.next": "Далее", + "common.by": "автор", "common.total": "всего", "common.series": "серий", + "home.featured": "Избранная серия", "home.readNow": "Читать", "home.browse": "В библиотеку", + "home.welcome": "Добро пожаловать в Full Story Novel", "home.emptyHint": "Добавьте новеллы в админ-панели.", + "home.popularGenres": "Популярные жанры", "home.noGenres": "Пока нет жанров", + "home.popular": "Популярное за неделю", "home.new": "Новинки", "home.updated": "Недавно обновлено", + "home.nothing": "Пока пусто.", + "novels.title": "Новеллы", "novels.search": "Поиск", "novels.genre": "Жанр", + "novels.status": "Статус", "novels.language": "Язык", "novels.sort": "Сортировка", + "novels.placeholder": "Название или автор", "novels.empty": "Ничего не найдено.", + "status.ONGOING": "В процессе", "status.COMPLETED": "Завершено", "status.HIATUS": "Пауза", + "lang.ENGLISH": "Английский", "lang.CHINESE": "Китайский", "lang.KOREAN": "Корейский", + "sort.popular": "Популярные", "sort.rating": "Рейтинг", "sort.newest": "Новые", + "sort.updated": "Обновления", "sort.name": "Имя", "sort.chapters": "Главы", + "genres.title": "Жанры", "genres.explore": "Исследуйте этот жанр.", + "novel.start": "Начать чтение", "novel.noChapters": "Пока нет глав", + "novel.back": "К библиотеке", "novel.chapters": "Главы", "novel.chapter": "Глава", + "novel.nonePublished": "Нет опубликованных глав.", + "read.prev": "← Назад", "read.next": "Далее →", "read.chapters": "Оглавление", + }, + ur: { + "nav.home": "ہوم", "nav.novels": "ناول", "nav.genres": "اقسام", + "search.placeholder": "ناول تلاش کریں...", "search.button": "تلاش", + "prefs.language": "زبان", "prefs.theme": "تھیم", "prefs.themeLight": "دن", "prefs.themeDark": "رات", + "footer.tagline": "کلاسک ویب ناول پلیٹ فارمز سے متاثر سلسلہ وار فنتاسی مطالعہ۔", + "footer.browse": "براؤز", "footer.admin": "ایڈمن", + "common.loading": "لوڈ ہو رہا ہے…", "common.unknown": "نامعلوم", "common.any": "کوئی بھی", + "common.viewAll": "سب دیکھیں", "common.chapters": "ابواب", "common.views": "ملاحظات", + "common.failed": "لوڈ ناکام", "common.previous": "پچھلا", "common.next": "اگلا", + "common.by": "از", "common.total": "کل", "common.series": "سیریز", + "home.featured": "نمایاں سیریز", "home.readNow": "ابھی پڑھیں", "home.browse": "لائبریری دیکھیں", + "home.welcome": "Full Story Novel میں خوش آمدید", "home.emptyHint": "شروع کرنے کے لیے ایڈمن سے ناول شامل کریں۔", + "home.popularGenres": "مقبول اقسام", "home.noGenres": "ابھی کوئی قسم نہیں", + "home.popular": "اس ہفتے مقبول", "home.new": "نئی ریلیز", "home.updated": "حال ہی میں اپڈیٹ", + "home.nothing": "ابھی کچھ نہیں۔", + "novels.title": "ناول", "novels.search": "تلاش", "novels.genre": "قسم", + "novels.status": "حیثیت", "novels.language": "زبان", "novels.sort": "ترتیب", + "novels.placeholder": "عنوان یا مصنف", "novels.empty": "کوئی ناول نہیں ملا۔", + "status.ONGOING": "جاری", "status.COMPLETED": "مکمل", "status.HIATUS": "وقفہ", + "lang.ENGLISH": "انگریزی", "lang.CHINESE": "چینی", "lang.KOREAN": "کوریائی", + "sort.popular": "مقبول", "sort.rating": "ریٹنگ", "sort.newest": "نیا", + "sort.updated": "اپڈیٹ", "sort.name": "نام", "sort.chapters": "ابواب", + "genres.title": "اقسام", "genres.explore": "اس قسم کو دیکھیں۔", + "novel.start": "پڑھنا شروع کریں", "novel.noChapters": "ابھی ابواب نہیں", + "novel.back": "لائبریری پر واپس", "novel.chapters": "ابواب", "novel.chapter": "باب", + "novel.nonePublished": "کوئی باب شائع نہیں۔", + "read.prev": "→ پچھلا", "read.next": "اگلا ←", "read.chapters": "ابواب", + }, + ja: { + "nav.home": "ホーム", "nav.novels": "小説", "nav.genres": "ジャンル", + "search.placeholder": "小説を検索...", "search.button": "検索", + "prefs.language": "言語", "prefs.theme": "テーマ", "prefs.themeLight": "ライト", "prefs.themeDark": "ダーク", + "footer.tagline": "古典的なウェブ小説プラットフォームに着想を得た連載ファンタジー。", + "footer.browse": "閲覧", "footer.admin": "管理", + "common.loading": "読み込み中…", "common.unknown": "不明", "common.any": "すべて", + "common.viewAll": "すべて見る", "common.chapters": "章", "common.views": "閲覧", + "common.failed": "読み込み失敗", "common.previous": "前へ", "common.next": "次へ", + "common.by": "著者", "common.total": "合計", "common.series": "作品", + "home.featured": "注目作品", "home.readNow": "今すぐ読む", "home.browse": "ライブラリを見る", + "home.welcome": "Full Story Novel へようこそ", "home.emptyHint": "管理画面から小説を追加してください。", + "home.popularGenres": "人気ジャンル", "home.noGenres": "ジャンルはまだありません", + "home.popular": "今週の人気", "home.new": "新着", "home.updated": "最近更新", + "home.nothing": "まだありません。", + "novels.title": "小説", "novels.search": "検索", "novels.genre": "ジャンル", + "novels.status": "状態", "novels.language": "言語", "novels.sort": "並び替え", + "novels.placeholder": "タイトルまたは著者", "novels.empty": "一致する小説がありません。", + "status.ONGOING": "連載中", "status.COMPLETED": "完結", "status.HIATUS": "休載", + "lang.ENGLISH": "英語", "lang.CHINESE": "中国語", "lang.KOREAN": "韓国語", + "sort.popular": "人気", "sort.rating": "評価", "sort.newest": "新着", + "sort.updated": "更新", "sort.name": "名前", "sort.chapters": "章数", + "genres.title": "ジャンル", "genres.explore": "このジャンルを見る。", + "novel.start": "読み始める", "novel.noChapters": "章はまだありません", + "novel.back": "ライブラリに戻る", "novel.chapters": "目次", "novel.chapter": "第", + "novel.nonePublished": "公開された章はありません。", + "read.prev": "← 前の章", "read.next": "次の章 →", "read.chapters": "目次", + }, + ko: { + "nav.home": "홈", "nav.novels": "소설", "nav.genres": "장르", + "search.placeholder": "소설 검색...", "search.button": "검색", + "prefs.language": "언어", "prefs.theme": "테마", "prefs.themeLight": "라이트", "prefs.themeDark": "다크", + "footer.tagline": "클래식 웹소설 플랫폼에서 영감을 받은 연재 판타지 독서.", + "footer.browse": "둘러보기", "footer.admin": "관리", + "common.loading": "로딩 중…", "common.unknown": "알 수 없음", "common.any": "전체", + "common.viewAll": "전체 보기", "common.chapters": "화", "common.views": "조회", + "common.failed": "불러오기 실패", "common.previous": "이전", "common.next": "다음", + "common.by": "작가", "common.total": "총", "common.series": "작품", + "home.featured": "추천 작품", "home.readNow": "바로 읽기", "home.browse": "서재 보기", + "home.welcome": "Full Story Novel에 오신 것을 환영합니다", "home.emptyHint": "관리자에서 소설을 추가하세요.", + "home.popularGenres": "인기 장르", "home.noGenres": "장르가 없습니다", + "home.popular": "이번 주 인기", "home.new": "신작", "home.updated": "최근 업데이트", + "home.nothing": "아직 없습니다.", + "novels.title": "소설", "novels.search": "검색", "novels.genre": "장르", + "novels.status": "상태", "novels.language": "언어", "novels.sort": "정렬", + "novels.placeholder": "제목 또는 작가", "novels.empty": "일치하는 소설이 없습니다.", + "status.ONGOING": "연재중", "status.COMPLETED": "완결", "status.HIATUS": "휴재", + "lang.ENGLISH": "영어", "lang.CHINESE": "중국어", "lang.KOREAN": "한국어", + "sort.popular": "인기", "sort.rating": "평점", "sort.newest": "최신", + "sort.updated": "업데이트", "sort.name": "이름", "sort.chapters": "화수", + "genres.title": "장르", "genres.explore": "이 장르를 탐색하세요.", + "novel.start": "읽기 시작", "novel.noChapters": "아직 회차가 없습니다", + "novel.back": "서재로 돌아가기", "novel.chapters": "목차", "novel.chapter": "제", + "novel.nonePublished": "게시된 회차가 없습니다.", + "read.prev": "← 이전", "read.next": "다음 →", "read.chapters": "목차", + }, + fr: { + "nav.home": "Accueil", "nav.novels": "Romans", "nav.genres": "Genres", + "search.placeholder": "Rechercher des romans...", "search.button": "Rechercher", + "prefs.language": "Langue", "prefs.theme": "Thème", "prefs.themeLight": "Clair", "prefs.themeDark": "Sombre", + "footer.tagline": "Lecture fantasy en série inspirée des plateformes de web novels classiques.", + "footer.browse": "Parcourir", "footer.admin": "Admin", + "common.loading": "Chargement…", "common.unknown": "Inconnu", "common.any": "Tous", + "common.viewAll": "Tout voir", "common.chapters": "chapitres", "common.views": "vues", + "common.failed": "Échec du chargement", "common.previous": "Précédent", "common.next": "Suivant", + "common.by": "par", "common.total": "total", "common.series": "séries", + "home.featured": "Série à la une", "home.readNow": "Lire maintenant", "home.browse": "Parcourir la bibliothèque", + "home.welcome": "Bienvenue sur Full Story Novel", "home.emptyHint": "Ajoutez des romans depuis l’admin.", + "home.popularGenres": "Genres populaires", "home.noGenres": "Pas encore de genres", + "home.popular": "Populaire cette semaine", "home.new": "Nouveautés", "home.updated": "Récemment mis à jour", + "home.nothing": "Rien ici pour l’instant.", + "novels.title": "Romans", "novels.search": "Recherche", "novels.genre": "Genre", + "novels.status": "Statut", "novels.language": "Langue", "novels.sort": "Tri", + "novels.placeholder": "Titre ou auteur", "novels.empty": "Aucun roman trouvé.", + "status.ONGOING": "En cours", "status.COMPLETED": "Terminé", "status.HIATUS": "Pause", + "lang.ENGLISH": "Anglais", "lang.CHINESE": "Chinois", "lang.KOREAN": "Coréen", + "sort.popular": "Populaire", "sort.rating": "Note", "sort.newest": "Récent", + "sort.updated": "Mis à jour", "sort.name": "Nom", "sort.chapters": "Chapitres", + "genres.title": "Genres", "genres.explore": "Explorez ce genre.", + "novel.start": "Commencer", "novel.noChapters": "Pas encore de chapitres", + "novel.back": "Retour à la bibliothèque", "novel.chapters": "Chapitres", "novel.chapter": "Chapitre", + "novel.nonePublished": "Aucun chapitre publié.", + "read.prev": "← Précédent", "read.next": "Suivant →", "read.chapters": "Chapitres", + }, + de: { + "nav.home": "Start", "nav.novels": "Romane", "nav.genres": "Genres", + "search.placeholder": "Romane suchen...", "search.button": "Suchen", + "prefs.language": "Sprache", "prefs.theme": "Thema", "prefs.themeLight": "Hell", "prefs.themeDark": "Dunkel", + "footer.tagline": "Serielle Fantasy-Lektüre inspiriert von klassischen Webnovel-Plattformen.", + "footer.browse": "Stöbern", "footer.admin": "Admin", + "common.loading": "Laden…", "common.unknown": "Unbekannt", "common.any": "Alle", + "common.viewAll": "Alle anzeigen", "common.chapters": "Kapitel", "common.views": "Aufrufe", + "common.failed": "Laden fehlgeschlagen", "common.previous": "Zurück", "common.next": "Weiter", + "common.by": "von", "common.total": "gesamt", "common.series": "Serien", + "home.featured": "Empfohlene Serie", "home.readNow": "Jetzt lesen", "home.browse": "Bibliothek öffnen", + "home.welcome": "Willkommen bei Full Story Novel", "home.emptyHint": "Füge Romane im Admin-Bereich hinzu.", + "home.popularGenres": "Beliebte Genres", "home.noGenres": "Noch keine Genres", + "home.popular": "Beliebt diese Woche", "home.new": "Neuerscheinungen", "home.updated": "Kürzlich aktualisiert", + "home.nothing": "Noch nichts hier.", + "novels.title": "Romane", "novels.search": "Suche", "novels.genre": "Genre", + "novels.status": "Status", "novels.language": "Sprache", "novels.sort": "Sortierung", + "novels.placeholder": "Titel oder Autor", "novels.empty": "Keine Treffer.", + "status.ONGOING": "Laufend", "status.COMPLETED": "Abgeschlossen", "status.HIATUS": "Pause", + "lang.ENGLISH": "Englisch", "lang.CHINESE": "Chinesisch", "lang.KOREAN": "Koreanisch", + "sort.popular": "Beliebt", "sort.rating": "Bewertung", "sort.newest": "Neu", + "sort.updated": "Aktualisiert", "sort.name": "Name", "sort.chapters": "Kapitel", + "genres.title": "Genres", "genres.explore": "Dieses Genre entdecken.", + "novel.start": "Lesen starten", "novel.noChapters": "Noch keine Kapitel", + "novel.back": "Zur Bibliothek", "novel.chapters": "Kapitel", "novel.chapter": "Kapitel", + "novel.nonePublished": "Keine veröffentlichten Kapitel.", + "read.prev": "← Zurück", "read.next": "Weiter →", "read.chapters": "Kapitel", + }, + it: { + "nav.home": "Home", "nav.novels": "Romanzi", "nav.genres": "Generi", + "search.placeholder": "Cerca romanzi...", "search.button": "Cerca", + "prefs.language": "Lingua", "prefs.theme": "Tema", "prefs.themeLight": "Chiaro", "prefs.themeDark": "Scuro", + "footer.tagline": "Lettura fantasy a puntate ispirata alle piattaforme di web novel classiche.", + "footer.browse": "Sfoglia", "footer.admin": "Admin", + "common.loading": "Caricamento…", "common.unknown": "Sconosciuto", "common.any": "Qualsiasi", + "common.viewAll": "Vedi tutto", "common.chapters": "capitoli", "common.views": "visualizzazioni", + "common.failed": "Caricamento non riuscito", "common.previous": "Precedente", "common.next": "Successivo", + "common.by": "di", "common.total": "totale", "common.series": "serie", + "home.featured": "Serie in evidenza", "home.readNow": "Leggi ora", "home.browse": "Sfoglia la libreria", + "home.welcome": "Benvenuto su Full Story Novel", "home.emptyHint": "Aggiungi romanzi dal pannello admin.", + "home.popularGenres": "Generi popolari", "home.noGenres": "Nessun genere ancora", + "home.popular": "Popolari questa settimana", "home.new": "Nuove uscite", "home.updated": "Aggiornati di recente", + "home.nothing": "Ancora niente qui.", + "novels.title": "Romanzi", "novels.search": "Cerca", "novels.genre": "Genere", + "novels.status": "Stato", "novels.language": "Lingua", "novels.sort": "Ordina", + "novels.placeholder": "Titolo o autore", "novels.empty": "Nessun romanzo trovato.", + "status.ONGOING": "In corso", "status.COMPLETED": "Completato", "status.HIATUS": "Pausa", + "lang.ENGLISH": "Inglese", "lang.CHINESE": "Cinese", "lang.KOREAN": "Coreano", + "sort.popular": "Popolari", "sort.rating": "Valutazione", "sort.newest": "Nuovi", + "sort.updated": "Aggiornati", "sort.name": "Nome", "sort.chapters": "Capitoli", + "genres.title": "Generi", "genres.explore": "Esplora questo genere.", + "novel.start": "Inizia a leggere", "novel.noChapters": "Ancora nessun capitolo", + "novel.back": "Torna alla libreria", "novel.chapters": "Capitoli", "novel.chapter": "Capitolo", + "novel.nonePublished": "Nessun capitolo pubblicato.", + "read.prev": "← Precedente", "read.next": "Successivo →", "read.chapters": "Capitoli", + }, +}; + +let lang = DEFAULT_LANG; +let theme = "dark"; + +function t(key, fallback) { + const dict = I18N[lang] || I18N.en; + return dict[key] || I18N.en[key] || fallback || key; +} + +function applyDocumentLocale() { + document.documentElement.lang = lang; + document.documentElement.dir = RTL_LANGS.has(lang) ? "rtl" : "ltr"; +} + +function applyTheme() { + document.documentElement.setAttribute("data-theme", theme); + const btn = document.getElementById("theme-toggle"); + if (btn) { + btn.textContent = theme === "light" ? "☾" : "☀"; + btn.title = theme === "light" ? t("prefs.themeDark") : t("prefs.themeLight"); + btn.setAttribute("aria-label", btn.title); + } +} + +function applyStaticI18n() { + document.querySelectorAll("[data-i18n]").forEach((el) => { + el.textContent = t(el.getAttribute("data-i18n")); + }); + document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => { + el.setAttribute("placeholder", t(el.getAttribute("data-i18n-placeholder"))); + }); + const sel = document.getElementById("lang-select"); + if (sel) { + sel.setAttribute("aria-label", t("prefs.language")); + sel.title = t("prefs.language"); + } + applyTheme(); +} + +function initPrefs() { + try { + const savedLang = localStorage.getItem(LANG_KEY); + lang = savedLang && I18N[savedLang] ? savedLang : DEFAULT_LANG; + const savedTheme = localStorage.getItem(THEME_KEY); + theme = savedTheme === "light" || savedTheme === "dark" ? savedTheme : "dark"; + } catch (_) { + lang = DEFAULT_LANG; + theme = "dark"; + } + applyDocumentLocale(); + applyTheme(); + + const sel = document.getElementById("lang-select"); + if (sel) { + sel.innerHTML = LANGS.map( + (l) => `` + ).join(""); + sel.addEventListener("change", () => { + lang = sel.value; + try { + localStorage.setItem(LANG_KEY, lang); + } catch (_) {} + applyDocumentLocale(); + applyStaticI18n(); + render(); + }); + } + const themeBtn = document.getElementById("theme-toggle"); + if (themeBtn) { + themeBtn.addEventListener("click", () => { + theme = theme === "dark" ? "light" : "dark"; + try { + localStorage.setItem(THEME_KEY, theme); + } catch (_) {} + applyTheme(); + }); + } + applyStaticI18n(); +} + +const app = document.getElementById("app"); +const state = { genres: [], genreTree: [] }; + +function asGenreTree(list) { + const arr = Array.isArray(list) ? list : []; + if (arr.some((g) => Array.isArray(g.children) && g.children.length)) { + return arr.filter((g) => !g.parentId); + } + const parents = arr.filter((g) => !g.parentId); + const children = arr.filter((g) => g.parentId); + if (!parents.length) return arr; + return parents.map((p) => ({ + ...p, + children: children.filter((c) => c.parentId === p.id || c.parentSlug === p.slug), + })); +} + +function findGenreNode(tree, slug) { + const key = String(slug || "").toLowerCase(); + for (const p of tree) { + if (String(p.slug).toLowerCase() === key) return { parent: p, child: null }; + for (const c of p.children || []) { + if (String(c.slug).toLowerCase() === key) return { parent: p, child: c }; + } + } + return { parent: null, child: null }; +} + +async function api(path) { + const res = await fetch(path); + if (!res.ok) throw new Error((await res.text()) || res.statusText); + return res.json(); +} + +function escapeHtml(s) { + return String(s ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function cover(url, title) { + if (url) return url; + const initial = (title || "?").slice(0, 1).toUpperCase(); + const svg = encodeURIComponent( + `${initial}` + ); + return `data:image/svg+xml,${svg}`; +} + +function formatViews(v) { + const n = Number(v || 0); + if (n >= 1e6) return (n / 1e6).toFixed(1) + "M " + t("common.views"); + if (n >= 1e3) return (n / 1e3).toFixed(1) + "K " + t("common.views"); + return n + " " + t("common.views"); +} + +function statusLabel(s) { + return t("status." + s, s); +} + +function languageLabel(s) { + return t("lang." + s, s); +} + +function badges(novel) { + const items = [ + `${escapeHtml(statusLabel(novel.status))}`, + `${escapeHtml(languageLabel(novel.language))}`, + ...((novel.genres || []).slice(0, 3).map((g) => `${escapeHtml(g)}`)), + ]; + return `
${items.join("")}
`; +} + +function novelCard(n) { + return ` + + ${escapeHtml(n.title)} +
+

${escapeHtml(n.title)}

+
${escapeHtml(n.author || t("common.unknown"))} · ★ ${Number(n.rating || 0).toFixed(1)}
+
${n.chapterCount || 0} ${t("common.chapters")} · ${formatViews(n.views)}
+
+
`; +} + +function section(title, items) { + return ` + +
${(items || []).map(novelCard).join("") || `
${t("home.nothing")}
`}
`; +} + +function parseRoute() { + const path = location.pathname.replace(/\/+$/, "") || "/"; + const parts = path.split("/").filter(Boolean); + if (!parts.length) return { name: "home" }; + if (parts[0] === "novels") return { name: "novels" }; + if (parts[0] === "genres") return { name: "genres" }; + if (parts[0] === "download" || parts[0] === "app") return { name: "download" }; + if (parts[0] === "novel" && parts[1]) return { name: "novel", slug: decodeURIComponent(parts[1]) }; + if (parts[0] === "read" && parts[1] && parts[2]) { + return { + name: "read", + novelSlug: decodeURIComponent(parts[1]), + chapterSlug: decodeURIComponent(parts[2]), + }; + } + return { name: "home" }; +} + +async function renderHome() { + const data = await api("/api/home"); + state.genreTree = asGenreTree(data.genres || []); + state.genres = state.genreTree; + const featured = (data.featured || [])[0]; + app.innerHTML = ` +
+
+ ${ + featured + ? ` +
+
${t("home.featured")}
+

${escapeHtml(featured.title)}

+ ${badges(featured)} +

${escapeHtml((featured.synopsis || "").slice(0, 280))}${(featured.synopsis || "").length > 280 ? "…" : ""}

+ +
` + : `

${t("home.welcome")}

${t("home.emptyHint")}

` + } +
+
+

${t("home.popularGenres")}

+
+ ${state.genreTree + .map((g) => `${escapeHtml(g.name)}`) + .join("") || `${t("home.noGenres")}`} +
+
+
+ ${section(t("home.popular"), data.popular)} + ${section(t("home.new"), data.newReleases)} + ${section(t("home.updated"), data.recentlyUpdated)} + `; +} + +async function renderNovels() { + const params = new URLSearchParams(location.search); + const q = params.get("q") || ""; + const genre = params.get("genre") || ""; + const status = params.get("status") || ""; + const language = params.get("language") || ""; + const sort = params.get("sort") || "popular"; + const page = Number(params.get("page") || 0); + if (!state.genreTree.length) { + state.genreTree = asGenreTree(await api("/api/genres?tree=true")); + state.genres = state.genreTree; + } + const located = findGenreNode(state.genreTree, genre); + const parentSlug = located.parent ? located.parent.slug : ""; + const childSlug = located.child ? located.child.slug : ""; + const childOptions = located.parent ? located.parent.children || [] : []; + const filterSlug = childSlug || parentSlug || genre; + + const qs = new URLSearchParams({ page: String(page), size: "24", sort }); + if (q) qs.set("q", q); + if (filterSlug) qs.set("genre", filterSlug); + if (status) qs.set("status", status); + if (language) qs.set("language", language); + + const data = await api(`/api/novels?${qs}`); + app.innerHTML = ` +

${t("novels.title")}

${data.total} ${t("common.series")}
+
+ + + + + + +
+
${(data.items || []).map(novelCard).join("") || `
${t("novels.empty")}
`}
+
+ ${page > 0 ? `${t("common.previous")}` : ""} + ${page + 1 < (data.totalPages || 0) ? `${t("common.next")}` : ""} +
`; + + document.getElementById("filter-form").addEventListener("change", (ev) => { + const fd = new FormData(document.getElementById("filter-form")); + const next = new URLSearchParams(); + const parent = String(fd.get("parent") || "").trim(); + const tag = String(fd.get("tag") || "").trim(); + if (ev.target && ev.target.name === "parent") { + if (parent) next.set("genre", parent); + } else if (tag) { + next.set("genre", tag); + } else if (parent) { + next.set("genre", parent); + } + for (const [k, v] of fd.entries()) { + if (k === "parent" || k === "tag") continue; + if (String(v).trim()) next.set(k, String(v).trim()); + } + history.pushState({}, "", `/novels?${next}`); + render(); + }); + + function pageLink(p) { + const next = new URLSearchParams(params); + next.set("page", String(p)); + return `/novels?${next}`; + } +} + +async function renderGenres() { + const genres = asGenreTree(await api("/api/genres?tree=true")); + state.genreTree = genres; + app.innerHTML = ` +

${t("genres.title")}

+
+ ${genres + .map( + (g) => `` + ) + .join("")} +
`; +} + +async function renderNovel(slug) { + const [novel, chapters] = await Promise.all([ + api(`/api/novels/${encodeURIComponent(slug)}`), + api(`/api/novels/${encodeURIComponent(slug)}/chapters`), + ]); + const first = chapters[0]; + app.innerHTML = ` +
+ +
+

${escapeHtml(novel.title)}

+
${t("common.by")} ${escapeHtml(novel.author || t("common.unknown"))} · ★ ${Number(novel.rating || 0).toFixed(1)} · ${formatViews(novel.views)}
+ ${badges(novel)} +

${escapeHtml(novel.synopsis || "")}

+
+ ${first ? `${t("novel.start")}` : `${t("novel.noChapters")}`} + ${t("novel.back")} +
+
+
+
+

${t("novel.chapters")}

${chapters.length} ${t("common.total")}
+ ${ + chapters + .map( + (c) => ` +
${t("novel.chapter")} ${c.chapterNumber} · ${escapeHtml(c.title)}
+
${c.publishedAt ? new Date(c.publishedAt).toLocaleDateString(lang) : ""}
+
` + ) + .join("") || `
${t("novel.nonePublished")}
` + } +
`; +} + +async function renderRead(novelSlug, chapterSlug) { + const chapter = await api( + `/api/novels/${encodeURIComponent(novelSlug)}/chapters/${encodeURIComponent(chapterSlug)}` + ); + document.title = `${chapter.title} · ${chapter.novelTitle} · Full Story Novel`; + app.innerHTML = ` + `; + window.scrollTo({ top: 0, behavior: "smooth" }); +} + +/** Public download-info;未上架时回退站内 APK 镜像(不暴露内部仓库)。 */ +const APP_DOWNLOAD = { + infoUrl: "https://admin.wyrs365.com/api/public/novel-reader/download-info", + localApk: "/downloads/FullStoryNovel-release.apk", + packageName: "wf.wyrs.com", +}; + +async function resolveAndroidApkUrl() { + try { + const res = await fetch(APP_DOWNLOAD.infoUrl, { credentials: "omit", cache: "no-store" }); + if (res.ok) { + const json = await res.json(); + const data = json && json.data ? json.data : json; + if (data && data.releaseApkOnShelf && data.releaseApkUrl) { + return { url: data.releaseApkUrl, source: "admin", name: data.releaseApkFileName || "FullStoryNovel.apk" }; + } + if (data && data.appStoreOnShelf && data.appStoreUrl) { + return { url: data.appStoreUrl, source: "store", name: "App Store" }; + } + } + } catch (_) { + /* fall through */ + } + try { + const local = await fetch(APP_DOWNLOAD.localApk, { method: "HEAD", cache: "no-store" }); + if (local.ok) { + return { url: APP_DOWNLOAD.localApk, source: "site", name: "FullStoryNovel-release.apk" }; + } + } catch (_) { + /* fall through */ + } + return { + url: APP_DOWNLOAD.localApk, + source: "site", + name: "FullStoryNovel-release.apk", + }; +} + +function ensureDownloadFonts() { + if (document.getElementById("fsn-dl-fonts")) return; + const link = document.createElement("link"); + link.id = "fsn-dl-fonts"; + link.rel = "stylesheet"; + link.href = + "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Syne:wght@600;700;800&display=swap"; + document.head.appendChild(link); +} + +async function renderDownload() { + ensureDownloadFonts(); + document.body.classList.add("page-download"); + document.title = `${t("download.title")} · Full Story Novel`; + app.innerHTML = ` +
+ +
+
+ +

Full Story Novel

+
+

${t("download.platform")}

+

${t("download.title")}

+

${t("download.lead")}

+ +

${t("download.resolving")}

+ +
    +
  • ${t("download.feature1")}
  • +
  • ${t("download.feature2")}
  • +
  • ${t("download.feature3")}
  • +
+
    +
  1. ${t("download.hint1")}
  2. +
  3. ${t("download.hint2")}
  4. +
  5. ${t("download.hint3")}
  6. +
+
+
`; + + const primary = document.getElementById("dl-primary"); + const status = document.getElementById("dl-status"); + const meta = document.getElementById("dl-meta"); + const resolved = await resolveAndroidApkUrl(); + if (primary) { + primary.href = resolved.url; + if (resolved.source === "store") primary.setAttribute("target", "_blank"); + } + if (status) status.textContent = t("download.ready"); + if (meta) { + meta.hidden = true; + meta.textContent = resolved.name || ""; + } +} + +async function render() { + const route = parseRoute(); + document.title = "Full Story Novel"; + document.body.classList.toggle("page-download", route.name === "download"); + syncNavActive(); + app.innerHTML = `
${t("common.loading")}
`; + try { + if (route.name === "home") await renderHome(); + else if (route.name === "novels") await renderNovels(); + else if (route.name === "genres") await renderGenres(); + else if (route.name === "download") await renderDownload(); + else if (route.name === "novel") await renderNovel(route.slug); + else if (route.name === "read") await renderRead(route.novelSlug, route.chapterSlug); + else await renderHome(); + } catch (err) { + app.innerHTML = `
${t("common.failed")}: ${escapeHtml(err.message)}
`; + } + syncNavActive(); +} + +document.getElementById("search-form").addEventListener("submit", (e) => { + e.preventDefault(); + const q = document.getElementById("search-input").value.trim(); + history.pushState({}, "", q ? `/novels?q=${encodeURIComponent(q)}` : "/novels"); + render(); +}); + +document.addEventListener("click", (e) => { + const a = e.target.closest("a"); + if (!a) return; + const href = a.getAttribute("href"); + if (!href || href.startsWith("http") || href.startsWith("#") || a.target === "_blank") return; + if (href === "/privacy" || href === "/terms" || href === "/delete") return; + e.preventDefault(); + history.pushState({}, "", href); + render(); +}); + +window.addEventListener("popstate", render); +initPrefs(); +initMobileNav(); +render(); + +function initMobileNav() { + const topbar = document.querySelector(".topbar"); + const toggle = document.getElementById("nav-toggle"); + const nav = document.getElementById("site-nav"); + if (!topbar || !toggle || !nav) return; + + const setOpen = (open) => { + topbar.classList.toggle("is-nav-open", open); + toggle.setAttribute("aria-expanded", open ? "true" : "false"); + }; + + toggle.addEventListener("click", () => { + setOpen(!topbar.classList.contains("is-nav-open")); + }); + + nav.addEventListener("click", (e) => { + if (e.target.closest("a")) setOpen(false); + }); + + const appDl = document.getElementById("app-dl-link"); + if (appDl) { + appDl.addEventListener("click", () => setOpen(false)); + } + + document.addEventListener("click", (e) => { + if (!topbar.contains(e.target)) setOpen(false); + }); + + window.addEventListener("resize", () => { + if (window.matchMedia("(min-width: 901px)").matches) setOpen(false); + }); +} + +function syncNavActive() { + const path = location.pathname.replace(/\/+$/, "") || "/"; + document.querySelectorAll("#site-nav a, #app-dl-link").forEach((a) => { + const href = (a.getAttribute("href") || "").replace(/\/+$/, "") || "/"; + let active = false; + if (href === "/") active = path === "/" || path === ""; + else if (href === "/novels") active = path === "/novels" || path.startsWith("/novel/"); + else if (href === "/genres") active = path === "/genres"; + else if (href === "/download") active = path === "/download" || path === "/app"; + a.classList.toggle("active", active); + }); +} diff --git a/fullstorynovel-web/delete/index.html b/fullstorynovel-web/delete/index.html new file mode 100644 index 0000000..83b94a6 --- /dev/null +++ b/fullstorynovel-web/delete/index.html @@ -0,0 +1,88 @@ + + + + + + 注销 — Full Story Novel + + + + + + + +
+ +
+
+ +
+ + + diff --git a/fullstorynovel-web/nginx-legal.snippet b/fullstorynovel-web/nginx-legal.snippet new file mode 100644 index 0000000..dc7e3df --- /dev/null +++ b/fullstorynovel-web/nginx-legal.snippet @@ -0,0 +1,32 @@ +# Insert inside the fullstorynovel.com :8443 server, before "location /" that proxies to 127.0.0.1:8095. +# BEGIN fsn-site-js +location = /assets/js/site.js { + root /opt/fullstorynovel/static-override; + try_files /assets/js/site.js =404; + add_header Cache-Control "no-cache"; +} +# END fsn-site-js +# BEGIN fsn-home-shell +location = / { + root /opt/fullstorynovel/static-override; + try_files /public-index.html =404; + add_header Cache-Control "no-cache"; +} +# END fsn-home-shell +# BEGIN fsn-legal-pages +location = /privacy { + root /opt/fullstorynovel/static-override; + try_files /privacy/index.html =404; + add_header Cache-Control "no-cache"; +} +location = /terms { + root /opt/fullstorynovel/static-override; + try_files /terms/index.html =404; + add_header Cache-Control "no-cache"; +} +location = /delete { + root /opt/fullstorynovel/static-override; + try_files /delete/index.html =404; + add_header Cache-Control "no-cache"; +} +# END fsn-legal-pages diff --git a/fullstorynovel-web/privacy/index.html b/fullstorynovel-web/privacy/index.html new file mode 100644 index 0000000..7bf3d29 --- /dev/null +++ b/fullstorynovel-web/privacy/index.html @@ -0,0 +1,80 @@ + + + + + + 隐私政策 — Full Story Novel + + + + + + + +
+ +
+
+ +
+ + + diff --git a/fullstorynovel-web/public-index.html b/fullstorynovel-web/public-index.html new file mode 100644 index 0000000..ec88208 --- /dev/null +++ b/fullstorynovel-web/public-index.html @@ -0,0 +1,86 @@ + + + + + + + + + + Full Story Novel + + + + + + + + + + +
+
+ + Full Story Novel + Full Story Novel + + + + + Download App +
+ + + +
+
+
+ +
+
Loading…
+
+ + + + + + + diff --git a/fullstorynovel-web/terms/index.html b/fullstorynovel-web/terms/index.html new file mode 100644 index 0000000..bb71f64 --- /dev/null +++ b/fullstorynovel-web/terms/index.html @@ -0,0 +1,79 @@ + + + + + + 用户协议 — Full Story Novel + + + + + + + +
+ +
+
+ +
+ + + diff --git a/health-web/assets/icon.png b/health-web/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..253f14c84e40bff2c0c1010da33acb81750fb0c9 GIT binary patch literal 45802 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 KMNUMnLSTZ)rqJU6 literal 0 HcmV?d00001 diff --git a/health-web/assets/site.css b/health-web/assets/site.css new file mode 100644 index 0000000..101adc9 --- /dev/null +++ b/health-web/assets/site.css @@ -0,0 +1,210 @@ +:root { + --bg: #07111f; + --bg-2: #10233d; + --card: rgba(255, 255, 255, 0.06); + --line: rgba(255, 255, 255, 0.14); + --text: #f4f7fb; + --muted: #b7c4d6; + --gold: #f6c453; + --heart: #e23d4a; + --cyan: #8ec8ff; + --shadow: 0 18px 50px rgba(0, 0, 0, 0.28); +} + +* { box-sizing: border-box; } + +html { scroll-behavior: smooth; } + +body { + margin: 0; + min-height: 100vh; + color: var(--text); + background: + radial-gradient(900px 420px at 85% -10%, rgba(246, 196, 83, 0.16), transparent 60%), + radial-gradient(700px 380px at -10% 20%, rgba(226, 61, 74, 0.16), transparent 55%), + linear-gradient(180deg, var(--bg-2), var(--bg) 42%); + font-family: "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", "Microsoft YaHei", sans-serif; + line-height: 1.65; + overflow-x: hidden; +} + +img { max-width: 100%; height: auto; display: block; } + +a { color: var(--cyan); } + +.wrap { + width: min(1040px, calc(100% - 32px)); + margin: 0 auto; +} + +.top { + position: sticky; + top: 0; + z-index: 2; + background: rgba(7, 17, 31, 0.86); + backdrop-filter: blur(14px); + border-bottom: 1px solid var(--line); +} + +.top-inner, .foot-inner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 18px; + padding: 12px 0; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + color: var(--text); + text-decoration: none; + font-weight: 700; + letter-spacing: 0.01em; + min-width: 0; +} + +.brand img { + width: 40px; + height: 40px; + border-radius: 12px; + flex: 0 0 auto; +} + +.nav, .foot-links { + display: flex; + flex-wrap: wrap; + gap: 6px 14px; + margin-left: auto; + max-width: 100%; +} + +.nav a, .foot-links a { + color: var(--text); + text-decoration: none; + padding: 6px 0; + white-space: nowrap; +} + +.nav a[aria-current="page"], .foot-links a[aria-current="page"] { + color: var(--gold); + font-weight: 700; +} + +main { padding: 28px 0 48px; } + +.hero, .panel, .card { + background: var(--card); + border: 1px solid var(--line); + border-radius: 22px; + box-shadow: var(--shadow); +} + +.hero { + display: grid; + grid-template-columns: 148px minmax(0, 1fr); + gap: 22px; + align-items: center; + padding: 28px; +} + +.hero img { + width: 148px; + height: 148px; + border-radius: 32px; +} + +h1, h2, h3 { line-height: 1.3; margin: 0 0 0.4em; } + +h1 { font-size: clamp(1.7rem, 4vw, 2.4rem); } + +.lead { color: var(--muted); margin: 0 0 1rem; font-size: 1.05rem; } + +.actions { display: flex; flex-wrap: wrap; gap: 10px; } + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 42px; + padding: 8px 16px; + border-radius: 999px; + text-decoration: none; + border: 1px solid transparent; +} + +.btn-primary { background: var(--heart); color: white; font-weight: 700; } +.btn-ghost { color: var(--text); border-color: var(--line); background: rgba(255, 255, 255, 0.04); } + +.note { + margin-top: 16px; + color: var(--gold); + font-size: 0.95rem; +} + +.section { margin-top: 28px; } + +.grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.card { padding: 16px 16px 18px; min-width: 0; } +.card h2, .card h3 { font-size: 1.05rem; } +.card p, .panel p, .panel li { color: var(--muted); } +.card p { margin: 0; } + +.panel { padding: 22px 22px 8px; overflow-wrap: anywhere; } +.panel h1 { margin-bottom: 0.3em; } +.panel .meta { color: var(--muted); margin-top: 0; } +.panel h2 { font-size: 1.08rem; margin-top: 1.3rem; } +.panel h3 { font-size: 1rem; margin-top: 1rem; } +.panel ul, .panel ol { padding-left: 1.2rem; } +.steps { counter-reset: step; list-style: none; padding-left: 0; } +.steps li { + position: relative; + padding: 10px 10px 10px 46px; + margin: 0 0 10px; + background: rgba(255, 255, 255, 0.04); + border-radius: 14px; + color: var(--text); +} +.steps li::before { + counter-increment: step; + content: counter(step); + position: absolute; + left: 10px; + top: 10px; + width: 26px; + height: 26px; + border-radius: 50%; + background: var(--heart); + color: white; + display: grid; + place-items: center; + font-size: 0.85rem; + font-weight: 700; +} + +.foot { + border-top: 1px solid var(--line); + color: var(--muted); +} + +.foot strong { color: var(--text); } +.foot p { margin: 4px 0 0; } + +@media (max-width: 800px) { + .grid { grid-template-columns: 1fr; } + .nav, .foot-links { margin-left: 0; width: 100%; } +} + +@media (max-width: 420px) { + .wrap { width: min(1040px, calc(100% - 24px)); } + .hero { grid-template-columns: 1fr; padding: 18px; } + .hero img { width: 96px; height: 96px; border-radius: 24px; } + .panel { padding: 16px 14px 6px; } + .actions .btn { width: 100%; } +} diff --git a/health-web/delete/index.html b/health-web/delete/index.html new file mode 100644 index 0000000..427ef95 --- /dev/null +++ b/health-web/delete/index.html @@ -0,0 +1,70 @@ + + + + + + 注销 — Sleep Heart + + + + + +
+ +
+
+
+
+

注销账号

+

注销在 Sleep Heart 应用里完成。本网站不提供在线注销表单。

+

入口

+

需要先登录。未登录时,「设置」里不会出现「账号与安全」。

+
    +
  1. 打开底部「我的」。
  2. +
  3. 进入「设置」。
  4. +
  5. 进入「账号与安全」。
  6. +
  7. 点「注销账号」。
  8. +
+

确认

+

应用会先询问「确认注销账号?」。说明文字是:注销将清除本机账号数据并退出登录,此操作不可撤销。随后会再询问「再次确认注销」,说明将清除本机昵称和头像并退出登录。两步都会同时提示:退出登录或注销应用账号不会取消 Apple 订阅。

+

确认之后会发生什么

+
    +
  • 清除这台设备上该账号的昵称、头像和会员标记,并退出登录。
  • +
  • 如果当时还持有登录凭证,应用会尝试通知服务器结束当前登录会话。
  • +
  • 当前应用代码不会向服务器提交永久删除账号。
  • +
  • 不会取消 Apple 订阅。若要停止续费,请在 iPhone 的「设置」里打开顶部 Apple 账户,进入「订阅」,选择 SleepHeart 中要停止续费的项目。
  • +
+

也可以在「我的」里提交「意见反馈」,或发邮件到 asd272940236@foxmail.com。

+

返回首页

+
+
+
+ + + diff --git a/health-web/download/index.html b/health-web/download/index.html new file mode 100644 index 0000000..1b1fe4e --- /dev/null +++ b/health-web/download/index.html @@ -0,0 +1,54 @@ + + + + + + 下载 — Sleep Heart + + + + + +
+ +
+
+
+
+

下载

+

Sleep Heart 是 iPhone 应用,应用内展示名为 SleepHeart。

+

应用即将上线。目前没有已核实、可以公开打开的 App Store 或安卓应用市场地址,本页不放置商店链接。

+

上架之后,请在 App Store 按应用名 SleepHeart 查找。请从本站公布的正式地址或 Apple 的商店页面安装,不要从未经授权的来源安装安装包。

+

返回首页

+
+
+
+ + + diff --git a/health-web/index.html b/health-web/index.html new file mode 100644 index 0000000..a4e5084 --- /dev/null +++ b/health-web/index.html @@ -0,0 +1,90 @@ + + + + + + Sleep Heart + + + + + +
+ +
+
+
+
+ Sleep Heart 图标 +
+

Sleep Heart

+

在这台 iPhone 上查看夜间声音环境、估算指尖心率,并记下你自己填写的日常健康数字。可以听助眠音乐,按节奏做呼吸练习,登录后也可以在广场发帖。

+ +

测量和监测结果只供个人参考,不是医疗诊断,不能代替血压计、血糖仪、血氧仪、体温计或就医。

+
+
+ +
+

可以做什么

+
+
+

音乐

+

播放助眠内容。场景包括自然白噪、深度冥想、呼吸疗愈、治愈乐器和脑波音乐。播放方式有顺序、随机、循环和勾选播放。

+
+
+

心率

+

用后置相机和闪光灯做指尖光学估算。画面在本机处理,不会上传。同一页可以手填体重、步数、血压、血糖、经期、血氧、体温和饮水。

+
+
+

睡眠

+

打开监测后使用麦克风,在本机识别夜间声音并保存可回放的短片段。录音和识别结果只留在这台 iPhone,不会上传。

+
+
+

发现

+

进入广场和呼吸训练。呼吸练习有箱式、4-7-8、舒缓吸呼和共振四种节奏,可以静音,也可以用喜欢的音乐做背景。

+
+
+

我的

+

管理账号、会员、主题、语言和意见反馈。已登录后,注销入口在「设置」里的「账号与安全」。

+
+
+

会员

+

会员音频可完整播放并收藏,本地音频导入不限数量,指尖心率测量不限次数。不购买也可以使用免费音频、本机健康记录、睡眠声监测和呼吸训练。

+
+
+
+
+
+ + + diff --git a/health-web/nginx/health.wyrs365.com.conf b/health-web/nginx/health.wyrs365.com.conf new file mode 100644 index 0000000..7520f07 --- /dev/null +++ b/health-web/nginx/health.wyrs365.com.conf @@ -0,0 +1,74 @@ +# Sleep Heart website — health.wyrs365.com +# Public :443 is stream ssl_preread (stream.d/spark-sni.conf, default → 127.0.0.1:8443). +# TLS terminates on loopback :8443. Certificate is this name only. + +server { + listen 80; + listen [::]:80; + server_name health.wyrs365.com; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 127.0.0.1:8443 ssl proxy_protocol; + listen [::1]:8443 ssl proxy_protocol; + http2 on; + server_name health.wyrs365.com; + + ssl_certificate /etc/letsencrypt/live/health.wyrs365.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/health.wyrs365.com/privkey.pem; + ssl_session_timeout 1d; + ssl_session_cache shared:HealthSSL:10m; + + add_header Strict-Transport-Security "max-age=31536000" always; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + root /var/www/health.wyrs365.com; + index index.html; + charset utf-8; + + location = /favicon.ico { + try_files /assets/icon.png =404; + access_log off; + expires 7d; + } + + location /assets/ { + expires 7d; + add_header Cache-Control "public"; + try_files $uri =404; + } + + location = /download { + try_files /download/index.html =404; + } + + location = /delete { + try_files /delete/index.html =404; + } + + location = /privacy { + try_files /privacy/index.html =404; + } + + location = /terms { + try_files /terms/index.html =404; + } + + location = /download/ { return 301 /download$is_args$args; } + location = /delete/ { return 301 /delete$is_args$args; } + location = /privacy/ { return 301 /privacy$is_args$args; } + location = /terms/ { return 301 /terms$is_args$args; } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/health-web/privacy/index.html b/health-web/privacy/index.html new file mode 100644 index 0000000..e0ac8d3 --- /dev/null +++ b/health-web/privacy/index.html @@ -0,0 +1,141 @@ + + + + + + 隐私协议 — Sleep Heart + + + + + +
+ +
+
+
+
+

Sleep Heart 软件隐私权政策

+

适用于 Sleep Heart 网站(health.wyrs365.com)及 Sleep Heart 应用。

+

上海微雨如酥网络设计有限公司(以下简称「本公司」或「我们」)非常重视用户个人信息及隐私权的保护,因此我们制订了涵盖如何收集、存储、使用、共享和保护用户信息以及访问、更新、删除和保护这些信息方式的隐私政策。

+

本版本发布时间:2026年10月2日

+ +

引言

+

本政策包含以下内容:

+

一、我们如何收集和使用您的个人信息及其他信息

+

二、我们如何使用 Cookie 和同类技术

+

三、合法共享、转让、公开披露您个人信息的情形

+

四、我们如何安全地保护和保存您提供的信息

+

五、您如何访问和控制自己提供的个人信息

+

六、未成年人的个人信息保护

+

七、通知和修订

+

八、适用法律与争议解决

+

九、如何联系我们

+ +

特别提示

+

1、感谢您对 Sleep Heart 的使用及信任,我们将持续致力于为您提供更加安全可靠的服务。

+

2、Sleep Heart 的产品和/或服务适用本隐私政策。

+

3、若某一功能另有说明,该说明构成本隐私政策的一部分;如有不一致,以该功能的说明为准。

+

4、除非本隐私政策另有明确所指,本隐私政策不适用于通过我们的产品和/或服务而接入的由第三方独立为您提供的产品和/或服务。

+

5、本隐私政策仅适用于 14 周岁以上的用户。如果您是不满 14 周岁的儿童,请立即停止对本隐私政策的阅读和任何其他操作。

+ +

定义

+

1、【个人信息】个人信息是指以电子或者其他方式记录的能够单独或者与其他信息结合识别特定自然人身份或者反映特定自然人活动情况的各种信息。

+

2、【个人敏感信息】个人敏感信息是指一旦泄露、非法提供或滥用可能危害人身和财产安全,极易导致个人名誉、身心健康受到损害或歧视性待遇等的个人信息。健康相关记录可能属于此类信息。

+ +

一、我们如何收集和使用您的个人信息及其他信息

+

我们仅会出于以下合法目的,收集和使用您的个人信息及其他信息。

+

(一)您授权我们收集和使用您个人信息及其他信息的情形

+

Sleep Heart 提供助眠音乐、睡眠声监测、指尖心率估算、呼吸训练、健康指标记录、广场以及账号与意见反馈。测量和监测结果只供个人参考,不是医疗诊断。

+

【会发送到服务器的信息】登录时发送邮箱、验证码或密码、设备标识 IDFV 和设备名称,用于登录和识别设备。意见反馈会发送您填写的正文、可选邮箱和应用版本。登录后在广场发布的帖子、评论、点赞、关注和举报,通过 HTTPS 发送到服务器。

+

【只保存在这台设备上的信息】指尖测心率时的相机画面;睡眠声监测的录音和识别结果;您记录的体重、步数、血压、血糖、经期、血氧、体温和饮水;头像照片以及您导入的音乐。这些内容不会上传给我们。

+

【Apple 健康】您若打开某一指标的「同步 Apple Health」,对应记录写入您的 Apple 健康,不会因此上传给我们。

+

【蓝牙】只在本机连接您绑定的设备,不会因此上传扫描结果。

+

【权限】相机、麦克风、蓝牙、相册和健康权限,只在您使用对应功能时申请。同意本政策之前,不会读取用于登录上报的设备标识,也不会提前申请这些权限。您可以拒绝这些权限,对应功能不可用,其他功能仍可使用。

+

【我们不做的事】不使用 IDFA,不请求跨 App 跟踪,不出售个人信息。

+

【第三方处理】

+

会员通过 Apple 的 StoreKit 自动续期订阅处理。价格、续费和取消以 Apple 订阅管理中的显示为准。您主动打开 Apple 健康同步时,由 Apple 按其规则处理写入健康应用的记录。Sleep Heart 当前版本不接入统计类或广告类第三方 SDK,也不使用 IDFA。

+

(二)我们从第三方获得您个人信息及其他信息的情形

+

为完成 Apple 订阅状态核对,应用会在本机读取 StoreKit 交易结果。除此之外,我们不会为了扩充档案而向其他公司购买您的个人信息。

+

(三)您个人信息及其他信息使用的规则

+

我们会根据法律规定及本隐私政策的约定,为实现登录、意见反馈、广场互动和订阅状态展示而使用所收集的信息。

+ +

二、我们如何使用 Cookie 和同类技术

+

1、本网站是静态页面,不使用 Cookie 识别您。

+

2、Sleep Heart 的登录不依赖本网站 Cookie。我们不会把 Cookie 用于本政策所述目的之外的用途。

+ +

三、合法共享、转让、公开披露您个人信息的情形

+

(一)共享

+

我们不会与 Sleep Heart 及其关联公司以外的任何公司、组织和个人共享您的个人信息,但法律另有规定、取得您同意,或为完成 Apple 订阅与您主动开启的 Apple 健康同步所必需的情形除外。

+

(二)转让

+

我们不会将您的个人信息转让给任何公司、组织和个人,但取得同意或法律要求等情形除外。

+

(三)公开披露

+

我们仅会在取得您同意或法律要求的情况下,采取符合业界标准的安全防护措施公开披露您的个人信息。您在广场主动发布的内容,会按产品功能向其他用户展示。

+

(四)对第三方责任的声明

+

Apple 订阅和 Apple 健康有各自的规则。建议您同时查看 Apple 的隐私说明与服务条款。

+ +

四、我们如何安全地保护和保存您提供的信息

+

(一)信息的保存地点

+

发送到服务器的账号、意见反馈和广场内容通过 HTTPS 传输并保存在我们的服务器上,用于提供相应功能。只留在设备上的测量画面、录音和手填健康数字,不会因此上传。

+

(二)信息的保存期限

+

我们仅为实现服务目的所必需的时间保留已上传的个人信息。本机记录由您在设备上保留或清理。

+

(三)我们保护您个人信息的技术与措施

+

我们对服务器通信使用 HTTPS。我们采取合理可行的访问控制措施保护已上传的信息。

+ +

五、您如何访问和控制自己提供的个人信息

+

1、您可以在应用内查看账号、反馈和广场相关内容,并在本机查看或清理只保存在设备上的记录。

+

2、应用内注销的实际效果见注销页:清除本机昵称、头像和会员标记并退出登录,并尝试结束当前登录会话。当前版本不会向服务器提交永久删除账号。

+

3、您也可以通过本政策文末的邮箱提出访问、更正或删除请求。我们将在验证身份后五个工作日内处理。

+

4、在法律要求保留,或无法验证请求人身份时,我们可能无法回应部分请求。

+ +

六、未成年人的个人信息保护

+

1、若您是未满 18 周岁的未成年人,请在监护人监护、指导下并取得监护人同意后使用 Sleep Heart。

+

2、我们重视未成年人个人信息的保护。本政策不面向不满 14 周岁的儿童。

+

3、监护人应指导未成年人合理使用网络。

+ +

七、通知和修订

+

1、为提供服务,本隐私政策会不时更新。更新后将在本网站或应用内以适当方式提供。

+

2、如发生重大变更,我们将以更明确的方式告知您。

+

3、若您在更新生效后继续使用产品和/或服务,即表示您同意受修订后的本隐私政策约束。

+ +

八、适用法律与争议解决

+

1、【法律适用】本隐私政策的运行、解释和争议的解决适用中华人民共和国法律。

+

2、【争议解决】您和 Sleep Heart 就本隐私政策内容或其执行发生争议的,双方应友好协商解决。

+ +

九、如何联系我们

+

如您对本隐私政策或您的个人信息有任何问题、意见或建议,请发送电子邮件至 asd272940236@foxmail.com。

+

账号注销不在本网站提交。应用内入口和实际效果见注销页。

+

上海微雨如酥网络设计有限公司

+

返回首页 · 用户协议 · 注销

+
+
+
+ + + diff --git a/health-web/terms/index.html b/health-web/terms/index.html new file mode 100644 index 0000000..cb20d23 --- /dev/null +++ b/health-web/terms/index.html @@ -0,0 +1,118 @@ + + + + + + 用户协议 — Sleep Heart + + + + + +
+ +
+
+
+
+

Sleep Heart 软件用户服务协议

+

适用于 Sleep Heart 网站(health.wyrs365.com)及 Sleep Heart 应用。

+

本版本发布时间:2026年10月2日

+ +

一、导言

+

上海微雨如酥网络设计有限公司(以下简称「本公司」或「我们」)在此特别提醒您认真阅读、充分理解本《软件服务协议》(以下简称「本协议」):

+

1、本协议是您与上海微雨如酥网络设计有限公司之间关于您登录、使用「Sleep Heart」(下称「本软件」)所订立的协议。请仔细阅读。

+

2、本协议所称服务,是指本公司现在或将来通过本软件提供的助眠音乐、睡眠声监测、指尖心率估算、呼吸训练、健康指标记录、广场和账号功能(以下简称「服务」)。「用户」是指登录、使用、浏览本服务的个人或组织,更多时候称为「您」。

+

3、您下载或使用 Sleep Heart,即表示您同意接受本协议约束。本协议构成对双方有约束力的文件。

+ +

二、服务协议的确认及接受

+

本站电子服务的所有权和运作权归我们所有。您同意本协议及《Sleep Heart 软件隐私权政策》后,可以使用相应服务。本协议是处理双方权利义务的约定,法律另有强制性规定或双方另有约定的,从其规定。

+

1、【审慎阅读】请您审慎阅读并充分理解各条款,特别是免除或者限制责任的条款、法律适用和争议解决条款。

+

2、如您需要本公司对条款作出说明,请立即停止使用服务,并发送邮件至本公司客服邮箱。

+

3、您的登录、使用等行为视为接受本协议。

+

4、如果您未满 18 周岁,请在法定监护人陪同下阅读本协议,并在使用前取得监护人同意。

+

5、在中华人民共和国大陆地区现行法律法规允许的范围内,本公司可以拒绝服务、停止传输违法内容或对违规账号采取限制措施。

+

6、本软件的著作权及我们发布的内容受著作权法保护。上海微雨如酥网络设计有限公司保留本协议未明确授予的权利。

+

7、【协议修改】根据法律法规更新及运营需要,我们有权不时修改本协议。重大变更将在本网站或应用内告知。

+ +

三、软件的取得与更新

+

1、请通过本网站公布的下载说明,或通过得到本公司授权的应用商店取得本软件。下载页若写明应用即将上线,则表示当时还没有可公开访问的商店地址。

+

2、【从未授权第三方取得】如果您从未经本公司授权的第三方取得本软件,本公司无法保证该软件能够正常使用,也不对由此产生的损失负责。

+

3、为改善使用体验,本公司可能不时提供软件更新。请及时安装与您的系统兼容的版本。

+ +

四、服务方式

+

1、除免费功能外,本软件提供 Apple 自动续期订阅。订阅组名称为 SleepHeart VIP,包含周、月、季、年四档,四档权益相同,区别是周期。收费前,购买页会展示来自 App Store 的名称和价格。

+

2、确认购买后,才会通过 Apple 账户付款。您取消购买单时,不会完成这笔扣款。订阅按所选周期自动续期,除非您在当前周期结束至少 24 小时前取消。续费价格、管理和取消都在 Apple 的订阅管理里。

+

3、在 iPhone 上可以打开「设置」,点顶部的 Apple 账户,进入「订阅」,选择 SleepHeart 里要停止续费的项目并取消订阅。取消续订不影响已经生效的使用时间。

+

4、退出登录或注销应用账号不会取消 Apple 订阅。会员页可以恢复购买。会员状态跟随 Apple 购买账户,不由应用登录账号单独授予。

+

5、四档会员权益为:会员音频可以完整播放并加入收藏;本地音频导入不限数量;指尖心率测量不限次数。不购买也可以使用免费音频、本机健康记录、睡眠声监测和呼吸训练。

+

6、价格以 App Store 显示为准。商品信息可能变化,请以购买当时页面为准。

+

7、若购买或恢复遇到问题,可发送邮件至 asd272940236@foxmail.com。

+

8、您同意遵守法律及本协议,善意使用本软件,不利用本软件从事非法活动或侵犯他人权利。

+ +

五、服务规则

+

1、本软件中的广场内容由用户提供。您必须为自己账号下的行为负责。

+

2、您发布的内容并不代表本公司的观点。

+

3、您应对所发送信息的真实性、合法性和有效性负责。请不要发布垃圾信息、骚扰、虚假或违法内容。您可以使用举报功能报告上述内容。未登录不能使用广场互动。

+

4、本公司可能因业务需要变更、暂停、限制或终止全部或部分服务。

+

5、测量和监测结果只供个人参考,不是医疗诊断,不能代替血压计、血糖仪、血氧仪、体温计或就医。呼吸训练是放松节奏,不是治疗。睡眠声里的静音标记不是呼吸暂停诊断。

+

6、您使用本软件应当遵守宪法和法律,遵守公共秩序,尊重社会公德。

+

7、除非法律允许或本公司书面许可,您不得干扰本软件正常运行,或对本软件进行反向工程等破坏行为。

+

8、本公司可依法停止传输违反法律法规或本协议的内容,并采取相应措施。

+

9、本协议未书面明示授权的其他权利仍归本公司所有。

+

10、您使用 Apple 提供的订阅、健康或系统能力时,还应遵守 Apple 的相关条款。

+ +

六、您的权利与义务

+

1、本软件账号的所有权归本公司所有,您获得使用权。

+

2、您应妥善保管账号和密码。

+

3、您应合法、正确地使用本服务。

+

4、注销的实际效果以应用内流程和注销页为准。当前版本会清除本机昵称、头像和会员标记并退出登录,并尝试结束当前登录会话;不会取消 Apple 订阅,也不会向服务器提交永久删除账号。

+ +

七、隐私保护政策

+

1、保护用户个人信息是本公司的基本原则。我们将按照本协议及《Sleep Heart 软件隐私权政策》收集、使用和保存您的个人信息。

+

2、使用登录、意见反馈或广场时,您需要按页面提示提供相应信息。

+

3、只留在本机的测量画面、睡眠录音、手填健康数字、头像和导入音乐,不会因此上传给我们。

+

4、个人信息的具体规则以隐私权政策为准。

+ +

八、附则

+

1、请您特别阅读免除本公司责任和加重您义务的条款,并自行考虑风险。

+

2、本协议任何条款若被认定无效,不影响其余条款的效力。

+

3、本协议标题仅为方便阅读,不具有单独法律效力。

+

4、【法律适用】本协议的解释、效力及纠纷的解决,适用中华人民共和国法律。

+

5、【争议解决】若您和本公司发生纠纷,首先应友好协商;协商不成的,提交本公司所在地有管辖权的人民法院诉讼解决。

+

6、如因您违反法律或本协议给我们造成损失,您应赔偿相应损失。

+

若您对本协议有任何意见,请发送电子邮件至 asd272940236@foxmail.com。

+

上海微雨如酥网络设计有限公司

+

返回首页 · 隐私协议 · 注销

+
+
+
+ + + diff --git a/website/about/index.html b/website/about/index.html new file mode 100644 index 0000000..3bd9e84 --- /dev/null +++ b/website/about/index.html @@ -0,0 +1,74 @@ + + + + + + 关于 — Spark Messenger + + + + + + + + + + + + + +
+ + + Spark Messenger + + +
+ +
+

关于

+

Spark Messenger

+

Spark Messenger 是面向好友、群聊与朋友圈的即时通讯应用。安全、简洁、跨端同步。

+
+

把对话、群聊与朋友圈收进同一束火花——安静、可靠、随处可达。

+

少一层噪音,多一点连接。消息直达,媒体直传,跨设备保持同一条时间线。

+
+
    +
  • +

    一对一与群聊

    +

    长连接实时推送,撤回与删除按会话清晰落地。

    +
  • +
  • +

    朋友圈

    +

    图片与短文按可见范围发布,不挤占会话列表。

    +
  • +
  • +

    媒体直传

    +

    对象存储签名上传,聊天里只留引用,加载更快。

    +
  • +
+
+

Android 与 iOS 客户端陆续开放。运营说明见站内隐私协议与用户协议。

+ 返回首页 +
+
+ + + + diff --git a/website/assets/legal.css b/website/assets/legal.css new file mode 100644 index 0000000..c0d5eba --- /dev/null +++ b/website/assets/legal.css @@ -0,0 +1,229 @@ +:root { + color-scheme: light; + --ink: #1a221c; + --ink-soft: #3d4a40; + --mist: #f3efe6; + --paper: #faf7f0; + --card: rgba(255, 255, 255, 0.62); + --ember: #e35a1c; + --leaf: #2f6b4f; + --line: rgba(26, 34, 28, 0.14); + --font-display: "Bricolage Grotesque", "PingFang SC", "Hiragino Sans", "Noto Sans", "Segoe UI", sans-serif; + --font-body: "Source Serif 4", "Songti SC", "PingFang SC", "Hiragino Mincho ProN", "Yu Mincho", "Noto Serif CJK SC", "Geeza Pro", "Noto Naskh Arabic", "Thonburi", "Noto Sans Thai", Georgia, serif; +} + +:root[data-theme="dark"] { + color-scheme: dark; + --ink: #f4f0e6; + --ink-soft: #c9d2c8; + --mist: #101512; + --paper: #1a221c; + --card: rgba(26, 34, 28, 0.72); + --ember: #ff8a4a; + --leaf: #8fceb0; + --line: rgba(244, 240, 230, 0.16); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + color-scheme: dark; + --ink: #f4f0e6; + --ink-soft: #c9d2c8; + --mist: #101512; + --paper: #1a221c; + --card: rgba(26, 34, 28, 0.72); + --ember: #ff8a4a; + --leaf: #8fceb0; + --line: rgba(244, 240, 230, 0.16); + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + min-height: 100vh; + color: var(--ink); + background: + radial-gradient(900px 480px at 0% -10%, rgba(227, 90, 28, 0.16), transparent 55%), + linear-gradient(180deg, var(--paper), var(--mist)); + font-family: var(--font-body); + font-size: 1.0625rem; + line-height: 1.7; +} + +a { + color: var(--ember); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +.top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + max-width: 46rem; + margin: 0 auto; + padding: 1rem 1.15rem 0; +} + +.mark { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: var(--ink); + font-family: var(--font-display); + font-weight: 700; + text-decoration: none; +} + +.mark img { + width: 1.75rem; + height: 1.75rem; + border-radius: 0.4rem; +} + +.tools { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.tools select, +.tools button { + min-height: 2.75rem; + border: 1px solid var(--line); + border-radius: 0.55rem; + background: var(--card); + color: var(--ink); + font-family: var(--font-display); + font-size: 0.95rem; +} + +.tools select { + max-width: 9.5rem; + padding: 0 0.55rem; +} + +.tools button { + padding: 0 0.85rem; + font-weight: 650; + cursor: pointer; +} + +.tools select:focus, +.tools button:focus { + outline: 2px solid var(--ember); + outline-offset: 2px; +} + +main { + max-width: 46rem; + margin: 0 auto; + padding: 1.25rem 1.15rem 2.5rem; +} + +article { + background: var(--card); + border: 1px solid var(--line); + border-radius: 1rem; + padding: 1.25rem 1.15rem 1.5rem; +} + +article h1 { + margin: 0.35rem 0 0.75rem; + font-family: var(--font-display); + font-size: clamp(1.8rem, 5vw, 2.4rem); + line-height: 1.15; + letter-spacing: -0.03em; +} + +article h2 { + margin: 1.35rem 0 0.4rem; + font-family: var(--font-display); + font-size: 1.15rem; + line-height: 1.35; +} + +article p, +article li { + margin: 0.45rem 0; +} + +article ul { + margin: 0.35rem 0 0.6rem; + padding-inline-start: 1.2rem; +} + +.eyebrow { + margin: 0; + color: var(--leaf); + font-family: var(--font-display); + font-size: 0.82rem; + font-weight: 650; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.meta { + color: var(--ink-soft); +} + +.foot { + max-width: 46rem; + margin: 0 auto; + padding: 0 1.15rem 2.25rem; + color: var(--ink-soft); + font-family: var(--font-display); + font-size: 0.92rem; +} + +.foot p { + display: flex; + flex-wrap: wrap; + gap: 0.55rem 0.75rem; + margin: 0.35rem 0; +} + +.foot a { + color: inherit; +} + +[hidden] { + display: none !important; +} + +@media (max-width: 640px) { + .top { + align-items: flex-start; + flex-direction: column; + } + + .tools, + .tools select { + width: 100%; + max-width: none; + } + + .tools button { + flex: 0 0 auto; + } +} diff --git a/website/assets/legal.js b/website/assets/legal.js new file mode 100644 index 0000000..e7fff67 --- /dev/null +++ b/website/assets/legal.js @@ -0,0 +1,166 @@ +(() => { + const LANGS = [ + "zh-Hans", "zh-Hant", "en-US", "ja", "ko", "es-MX", "fr-FR", + "de-DE", "pt-BR", "ru", "ar-SA", "it", "th", "vi", "id", + ]; + + const UI = { + "zh-Hans": { home: "首页", privacy: "隐私政策", terms: "用户协议", toDark: "暗黑", toLight: "日间" }, + "zh-Hant": { home: "首頁", privacy: "隱私政策", terms: "使用者協議", toDark: "深色", toLight: "日間" }, + "en-US": { home: "Home", privacy: "Privacy Policy", terms: "Terms of Use", toDark: "Dark", toLight: "Light" }, + ja: { home: "ホーム", privacy: "プライバシー", terms: "利用規約", toDark: "ダーク", toLight: "ライト" }, + ko: { home: "홈", privacy: "개인정보", terms: "이용약관", toDark: "어둡게", toLight: "밝게" }, + "es-MX": { home: "Inicio", privacy: "Privacidad", terms: "Condiciones", toDark: "Oscuro", toLight: "Claro" }, + "fr-FR": { home: "Accueil", privacy: "Confidentialité", terms: "Conditions", toDark: "Sombre", toLight: "Clair" }, + "de-DE": { home: "Start", privacy: "Datenschutz", terms: "Nutzung", toDark: "Dunkel", toLight: "Hell" }, + "pt-BR": { home: "Início", privacy: "Privacidade", terms: "Termos", toDark: "Escuro", toLight: "Claro" }, + ru: { home: "Главная", privacy: "Конфиденциальность", terms: "Условия", toDark: "Тёмная", toLight: "Светлая" }, + "ar-SA": { home: "الرئيسية", privacy: "الخصوصية", terms: "الشروط", toDark: "داكن", toLight: "فاتح" }, + it: { home: "Home", privacy: "Privacy", terms: "Condizioni", toDark: "Scuro", toLight: "Chiaro" }, + th: { home: "หน้าแรก", privacy: "ความเป็นส่วนตัว", terms: "ข้อกำหนด", toDark: "มืด", toLight: "สว่าง" }, + vi: { home: "Trang chủ", privacy: "Quyền riêng tư", terms: "Điều khoản", toDark: "Tối", toLight: "Sáng" }, + id: { home: "Beranda", privacy: "Privasi", terms: "Ketentuan", toDark: "Gelap", toLight: "Terang" }, + }; + + const HTML_LANG = { + "zh-Hans": "zh-CN", + "zh-Hant": "zh-TW", + "en-US": "en", + ja: "ja", + ko: "ko", + "es-MX": "es-MX", + "fr-FR": "fr", + "de-DE": "de", + "pt-BR": "pt-BR", + ru: "ru", + "ar-SA": "ar", + it: "it", + th: "th", + vi: "vi", + id: "id", + }; + + function norm(tag) { + if (!tag) return ""; + const raw = String(tag).trim().replace(/_/g, "-"); + if (LANGS.includes(raw)) return raw; + const lower = raw.toLowerCase(); + if (lower === "zh" || lower === "zh-cn" || lower === "zh-sg" || lower === "zh-hans") return "zh-Hans"; + if (lower === "zh-tw" || lower === "zh-hk" || lower === "zh-mo" || lower === "zh-hant") return "zh-Hant"; + if (lower.startsWith("en")) return "en-US"; + if (lower.startsWith("ja")) return "ja"; + if (lower.startsWith("ko")) return "ko"; + if (lower.startsWith("es")) return "es-MX"; + if (lower.startsWith("fr")) return "fr-FR"; + if (lower.startsWith("de")) return "de-DE"; + if (lower.startsWith("pt")) return "pt-BR"; + if (lower.startsWith("ru")) return "ru"; + if (lower.startsWith("ar")) return "ar-SA"; + if (lower.startsWith("it")) return "it"; + if (lower.startsWith("th")) return "th"; + if (lower.startsWith("vi")) return "vi"; + if (lower === "id" || lower.startsWith("id-") || lower.startsWith("in")) return "id"; + return ""; + } + + function detectLang() { + const query = new URLSearchParams(location.search).get("lang"); + if (query) return norm(query) || "zh-Hans"; + try { + const saved = norm(localStorage.getItem("spark-legal-lang")); + if (saved) return saved; + } catch (_) { /* ignore */ } + const list = navigator.languages && navigator.languages.length + ? navigator.languages + : [navigator.language || ""]; + for (const item of list) { + const code = norm(item); + if (code) return code; + } + return "zh-Hans"; + } + + function effectiveTheme() { + const chosen = document.documentElement.getAttribute("data-theme"); + if (chosen === "dark" || chosen === "light") return chosen; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } + + function applyTheme(mode) { + if (mode === "light" || mode === "dark") { + document.documentElement.setAttribute("data-theme", mode); + } else { + document.documentElement.removeAttribute("data-theme"); + } + paintThemeButton(); + } + + function paintThemeButton() { + const button = document.getElementById("theme"); + const lang = document.documentElement.getAttribute("data-lang") || "zh-Hans"; + const ui = UI[lang] || UI["zh-Hans"]; + if (!button) return; + const dark = effectiveTheme() === "dark"; + button.textContent = dark ? ui.toLight : ui.toDark; + button.setAttribute("aria-pressed", dark ? "true" : "false"); + } + + function applyLang(code) { + const safe = LANGS.includes(code) ? code : "zh-Hans"; + document.querySelectorAll("article[data-lang]").forEach((node) => { + node.hidden = node.getAttribute("data-lang") !== safe; + }); + document.documentElement.lang = HTML_LANG[safe] || "zh-CN"; + document.documentElement.dir = safe === "ar-SA" ? "rtl" : "ltr"; + document.documentElement.setAttribute("data-lang", safe); + const heading = document.querySelector(`article[data-lang="${safe}"] h1`); + if (heading && heading.textContent) { + document.title = `${heading.textContent.trim()} — Spark Messenger`; + } + const ui = UI[safe]; + document.querySelectorAll("[data-i18n]").forEach((node) => { + const key = node.getAttribute("data-i18n"); + if (ui[key]) node.textContent = ui[key]; + }); + const select = document.getElementById("lang"); + if (select) select.value = safe; + paintThemeButton(); + } + + applyLang(detectLang()); + + try { + const savedTheme = localStorage.getItem("spark-legal-theme"); + if (savedTheme === "light" || savedTheme === "dark") applyTheme(savedTheme); + else paintThemeButton(); + } catch (_) { + paintThemeButton(); + } + + const select = document.getElementById("lang"); + if (select) { + select.addEventListener("change", () => { + applyLang(select.value); + try { localStorage.setItem("spark-legal-lang", select.value); } catch (_) { /* ignore */ } + const url = new URL(location.href); + url.searchParams.set("lang", select.value); + history.replaceState(null, "", url); + }); + } + + const themeButton = document.getElementById("theme"); + if (themeButton) { + themeButton.addEventListener("click", () => { + const next = effectiveTheme() === "dark" ? "light" : "dark"; + applyTheme(next); + try { localStorage.setItem("spark-legal-theme", next); } catch (_) { /* ignore */ } + }); + } + + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const onScheme = () => { + if (!document.documentElement.getAttribute("data-theme")) paintThemeButton(); + }; + if (typeof media.addEventListener === "function") media.addEventListener("change", onScheme); + else if (typeof media.addListener === "function") media.addListener(onScheme); +})(); diff --git a/website/assets/styles.css b/website/assets/styles.css index 5e8c763..be9779b 100644 --- a/website/assets/styles.css +++ b/website/assets/styles.css @@ -66,9 +66,10 @@ a { position: relative; z-index: 2; display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; - gap: 1rem; + gap: 0.75rem 1.25rem; max-width: 1120px; margin: 0 auto; padding: 1.25rem 1.5rem 0; @@ -96,21 +97,33 @@ a { .top-nav { display: flex; - gap: 1.25rem; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.15rem 1.15rem; font-family: var(--font-display); font-size: 0.95rem; font-weight: 500; } .top-nav a { + display: inline-flex; + align-items: center; + min-height: 2.25rem; + padding: 0.15rem 0.05rem; text-decoration: none; opacity: 0.78; } -.top-nav a:hover { +.top-nav a:hover, +.top-nav a[aria-current="page"] { opacity: 1; } +.top-nav a[aria-current="page"] { + color: var(--ember); + box-shadow: inset 0 -2px 0 var(--ember); +} + .hero { position: relative; z-index: 1; @@ -512,6 +525,67 @@ a { } } +.page { + position: relative; + z-index: 1; + max-width: 1120px; + margin: 0 auto; + padding: 2.5rem 1.5rem 4rem; +} + +.page h1 { + margin: 0; + font-family: var(--font-display); + font-weight: 800; + font-size: clamp(2.4rem, 6vw, 4rem); + line-height: 0.98; + letter-spacing: -0.04em; +} + +.page h2 { + margin: 2.2rem 0 0.55rem; + font-family: var(--font-display); + font-size: clamp(1.35rem, 3vw, 1.75rem); + font-weight: 700; + letter-spacing: -0.03em; +} + +.page .prose { + max-width: 40rem; + color: var(--ink-soft); +} + +.page .prose p, +.page .prose li { + margin: 0.75rem 0; +} + +.page .prose ul { + margin: 0.35rem 0 0; + padding-inline-start: 1.2rem; +} + +.page-back { + display: inline-flex; + margin-top: 1.75rem; + font-family: var(--font-display); + font-weight: 650; + color: var(--ember); + text-decoration: none; +} + +.page .feature-rail { + margin-top: 1.75rem; +} + +@media (max-width: 720px) { + .top-nav { + flex: 1 1 100%; + justify-content: flex-start; + column-gap: 0.95rem; + } +} + @media (max-width: 900px) { .hero { grid-template-columns: 1fr; diff --git a/website/delete/index.html b/website/delete/index.html new file mode 100644 index 0000000..3dba819 --- /dev/null +++ b/website/delete/index.html @@ -0,0 +1,59 @@ + + + + + + 注销 — Spark Messenger + + + + + + + + + + + + + +
+ + + Spark Messenger + + +
+ +
+

账号

+

注销

+

官网不能直接注销账号。请在已登录的 Spark Messenger 里完成。

+
+

在 App 内注销

+

使用当前账号发起注销后,系统会向这个账号绑定的邮箱发送注销验证码。验证码 5 分钟内有效,只发到绑定邮箱,不会发到手机号。验证通过并提交后,账号会被永久删除,不能再登录。

+

找不到入口时

+

如果 App 里暂时没有看到注销入口,请通过 App 内的「意见反馈」联系我们,说明需要注销账号。隐私协议也写明:可以通过意见反馈要求更正或删除与账号相关的信息。

+ 返回首页 +
+
+ + + + diff --git a/website/index.html b/website/index.html index 4f66b62..6e04ab2 100644 --- a/website/index.html +++ b/website/index.html @@ -25,6 +25,10 @@ @@ -98,6 +102,10 @@ sparkmessenger.wyrs365.com sparkmsg.wyrs365.com + + 隐私政策 + + 用户协议

diff --git a/website/legal/legal.html b/website/legal/legal.html new file mode 100644 index 0000000..c86b51e --- /dev/null +++ b/website/legal/legal.html @@ -0,0 +1,135 @@ + + + + + + Spark Messenger + + + +
+ Spark Messenger +
+ + +
+ +
+
+
+
+ + + + + + + + + + diff --git a/website/privacy/index.html b/website/privacy/index.html new file mode 100644 index 0000000..24c2cc5 --- /dev/null +++ b/website/privacy/index.html @@ -0,0 +1,3 @@ + + + diff --git a/website/terms/index.html b/website/terms/index.html new file mode 100644 index 0000000..b6b66c5 --- /dev/null +++ b/website/terms/index.html @@ -0,0 +1 @@ +