diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7a302de..c11043f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,12 @@
# 更新日志 / Changelog
-本项目按 `v主版本.次版本.修订版本` 标记发布。下列内容以对应 Git tag 和已提交功能为准。
+本项目按 `v主版本.次版本.修订版本` 标记应用版本;按需创建 Git tag。下列内容以对应提交和已发布功能为准。
+
+## v1.17.0 — 2026-09-28
+
+- 界面扩展至联合国六种工作语文,并加入日语和繁体中文(香港、澳门、台湾),覆盖设置、记忆、目标待办、日历、通知、数据库管理、终端及每日提示。
+- 自动语言识别按浏览器首选语言与地区代码选择;旧版简体中文偏好迁移到 `zh-CN`。新增阿拉伯语从右到左布局与各语种的日期、数字格式。
+- 按本次要求更新线上站点,不创建 Git tag。
## v1.16.0 — 2026-09-28
diff --git a/README.md b/README.md
index 976195e..e4ff8f7 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
以桌面操作系统为交互方式的个人记录网站。用 App 记录日常、管理目标与待办,并沿时间线回看已经留下的内容。
-在线站点:[Life OS](https://life-os-personal-desktop.biss0.chatgpt.site) · 当前应用版本:**v1.16.0** · [更新日志](CHANGELOG.md)
+在线站点:[Life OS](https://life-os-personal-desktop.biss0.chatgpt.site) · 当前应用版本:**v1.17.0** · [更新日志](CHANGELOG.md)
## 功能
@@ -22,7 +22,7 @@
通知权限需手动授予,开关和时间仅保存在当前浏览器。网页打开时检查提醒;关闭网站后无法保证定时送达,如需关闭网页后的可靠提醒,需要另行接入推送服务。
-桌面端支持单击选择、双击打开 App、多窗口、任务栏恢复、图标拖动排列和窗口布局记忆。拖动图标或小部件时会显示吸附网格;目标倒计时、时钟、今日概览与快捷操作小部件可移动、调整大小和分别开关。移动端以横排图标和全屏 App 为主,小部件在桌面下方排列。可选必应每日图片、内置背景或自定义 HTTPS 图片链接。中文与英文可自动跟随浏览器语言,也可手动选择。
+桌面端支持单击选择、双击打开 App、多窗口、任务栏恢复、图标拖动排列和窗口布局记忆。拖动图标或小部件时会显示吸附网格;目标倒计时、时钟、今日概览与快捷操作小部件可移动、调整大小和分别开关。移动端以横排图标和全屏 App 为主,小部件在桌面下方排列。可选必应每日图片、内置背景或自定义 HTTPS 图片链接。界面支持英语、简体中文、繁体中文(香港、澳门、台湾)、阿拉伯语、法语、俄语、西班牙语和日语;可按浏览器首选语言自动选择,也可手动切换。阿拉伯语使用从右到左的页面方向。
快捷键:`Ctrl / ⌘ + K` 打开启动器,`Ctrl / ⌘ + F` 搜索记忆,`Ctrl / ⌘ + S` 保存今日记忆;`Alt + 1–9` 打开原有 App,`Alt + 0` 打开终端。终端内 ↑↓ 浏览命令历史,Tab 补全命令,记忆正文输入时 `Ctrl / ⌘ + Enter` 保存。站点包含 PWA 清单和 Service Worker,离线时可打开已缓存的应用外壳;云同步、外部网页与必应图片仍需要联网。
@@ -50,7 +50,7 @@ npm run validate
npm run test:cloud
```
-`build` 将页面及 App 脚本打包成 `dist/server/` 中的 Cloudflare Worker 产物;`validate` 检查产物、双语键与主要交互;`test:cloud` 使用内存 SQLite 检查旧数据回填、版本冲突与租户隔离。生产托管配置在 `.openai/hosting.json`,含 Sites 项目 ID 和 D1 逻辑绑定。直接在其他环境部署时,需要提供兼容的 Worker 环境与 `DB` 绑定;云端 API 还依赖托管层提供的 `oai-authenticated-user-id` 请求头。
+`build` 将页面及 App 脚本打包成 `dist/server/` 中的 Cloudflare Worker 产物;`validate` 检查产物、十种语言的词条、占位符与主要交互;`test:cloud` 使用内存 SQLite 检查旧数据回填、版本冲突与租户隔离。生产托管配置在 `.openai/hosting.json`,含 Sites 项目 ID 和 D1 逻辑绑定。直接在其他环境部署时,需要提供兼容的 Worker 环境与 `DB` 绑定;云端 API 还依赖托管层提供的 `oai-authenticated-user-id` 请求头。
## 项目结构
@@ -62,11 +62,11 @@ public/browser-app.js iframe 浏览器
public/terminal-app.js 命令解析与终端界面
public/db-admin.js 数据库管理器界面
public/data-actions.js 图形界面与终端共用的数据写入
-public/i18n-renderers.js 记忆、洞察、设置等双语界面
+public/i18n-renderers.js 记忆、洞察、设置等多语言界面
public/desktop-personalization.js 图标、窗口与壁纸偏好
public/desktop-widgets.js 吸附网格与桌面小部件
public/addons.js 导入导出、云同步与辅助操作
-public/locales/ 中文、英文文案
+public/locales/ 十种语言的界面文案
public/service-worker.js PWA 缓存
worker/index.js Worker 路由与必应图片代理
worker/cloud-state.js 云端记录迁移与同步 API
diff --git a/package.json b/package.json
index 39338d6..ad90550 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "life-os",
- "version": "1.16.0",
+ "version": "1.17.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/public/addons.js b/public/addons.js
index 52904d6..03e0391 100644
--- a/public/addons.js
+++ b/public/addons.js
@@ -3,7 +3,7 @@
const DRAFT_KEY = 'lifeos_today_draft_v1';
const localDay = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; };
const escapeHtml = value => String(value || '').replace(/[&<>]/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
- const APP_VERSION = '1.16.0';
+ const APP_VERSION = '1.17.0';
window.LifeOSVersion = APP_VERSION;
const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'tasks', 'calendar', 'system', 'browser', 'terminal', 'database'];
const t = (key, values) => LifeI18n.t(key, values);
diff --git a/public/calendar.js b/public/calendar.js
index 23a0d47..cb17219 100644
--- a/public/calendar.js
+++ b/public/calendar.js
@@ -7,7 +7,7 @@
let selected = localKey(new Date()), month = new Date(`${selected}T12:00:00`), view = 'month';
const fromKey = key => new Date(`${key}T12:00:00`);
const esc = value => String(value || '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
- const formatDate = (key, options = { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }) => new Intl.DateTimeFormat(lang() === 'zh' ? 'zh-CN' : 'en-US', options).format(fromKey(key));
+ const formatDate = (key, options = { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }) => new Intl.DateTimeFormat(LifeI18n.locale(), options).format(fromKey(key));
const localDatePart = value => value ? localKey(new Date(value)) : '';
const system = () => { try { return JSON.parse(localStorage.getItem('lifeos_system') || '{}'); } catch { return {}; } };
@@ -55,7 +55,7 @@
}
function render(container) {
- const title = new Intl.DateTimeFormat(lang() === 'zh' ? 'zh-CN' : 'en-US', { month: 'long', year: 'numeric' }).format(month);
+ const title = new Intl.DateTimeFormat(LifeI18n.locale(), { month: 'long', year: 'numeric' }).format(month);
const heading = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'].map((key, index) => `${t(key)}`).join('');
container.innerHTML = `
${t('eyebrow')}
${view === 'month' ? `${heading}
${monthCells()}
${t('memory')}${t('task')}${t('goal')}
${selectedDayMarkup()}
` : `${selectedDayMarkup()}`}`;
}
diff --git a/public/i18n-renderers.js b/public/i18n-renderers.js
index b873dfc..55b6d2b 100644
--- a/public/i18n-renderers.js
+++ b/public/i18n-renderers.js
@@ -1,7 +1,7 @@
/* Render-time localisation layer. User-created records are deliberately never translated. */
(() => {
const t = (key, values) => LifeI18n.t(key, values);
- const locale = () => lang() === 'zh' ? 'zh-CN' : 'en-US';
+ const locale = () => LifeI18n.locale();
const weekday = value => new Intl.DateTimeFormat(locale(), { weekday: 'long' }).format(new Date(value + 'T12:00:00'));
const uiDate = value => new Intl.DateTimeFormat(locale(), { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }).format(new Date(value + 'T12:00:00'));
const short = value => new Intl.DateTimeFormat(locale(), { month: 'short', day: 'numeric' }).format(new Date(value + 'T12:00:00')).toUpperCase();
@@ -10,7 +10,7 @@
const linkedTasks = () => sys().tasksBoard?.tasks || [];
const escAttr = value => esc(value).replace(/\"/g, '"');
const titleOf = memory => memory.title?.trim() || memory.text?.trim().split('\n')[0].slice(0, 64) || t('timeline.untitled');
- const localizedFilmField = (memory, field) => { const index = LifeI18n.dictionaries.en.prompt.films.findIndex(film => film.title === memory.filmTitle); return index < 0 ? (memory[field] || '') : (LifeI18n.dictionaries[lang() === 'zh' ? 'zh' : 'en'].prompt.films[index]?.[field === 'filmTitle' ? 'title' : 'subtitle'] || memory[field] || ''); };
+ const localizedFilmField = (memory, field) => { const index = LifeI18n.supported.reduce((found, code) => found >= 0 ? found : LifeI18n.dictionaries[code]?.prompt.films.findIndex(film => film.title === memory.filmTitle), -1); return index < 0 ? (memory[field] || '') : (LifeI18n.dictionaries[lang()]?.prompt.films[index]?.[field === 'filmTitle' ? 'title' : 'subtitle'] || memory[field] || ''); };
const tagList = value => [...new Set(String(value || '').split(/[,,]/).map(tag => tag.trim()).filter(Boolean))].slice(0, 12);
const memoryMetaFields = memory => ``;
@@ -20,7 +20,7 @@
window.shortDate = short;
window.addMemoryForDate = date => { todayTargetDate = date; todayDraft = null; todayTitleDraft = null; openApp('today'); };
window.closeModal = () => { rootModal().innerHTML = ''; };
- window.applyLanguage = () => { document.documentElement.lang = lang() === 'zh' ? 'zh-CN' : 'en'; document.title = t('shell.pageTitle'); const brand = document.querySelector('#desktop-brand'), launcher = document.querySelector('#life-launcher'), boot = document.querySelector('#boot span'); if (brand) brand.textContent = t('shell.brand'); if (launcher) launcher.textContent = t('shell.life'); if (boot) boot.textContent = t('shell.initializing'); if (settings().wallpaper) applyWallpaper(settings().wallpaper); };
+ window.applyLanguage = () => { document.documentElement.lang = LifeI18n.locale(); document.documentElement.dir = lang() === 'ar' ? 'rtl' : 'ltr'; document.title = t('shell.pageTitle'); const brand = document.querySelector('#desktop-brand'), launcher = document.querySelector('#life-launcher'), boot = document.querySelector('#boot span'); if (brand) brand.textContent = t('shell.brand'); if (launcher) launcher.textContent = t('shell.life'); if (boot) boot.textContent = t('shell.initializing'); if (settings().wallpaper) applyWallpaper(settings().wallpaper); };
window.applyWallpaper = wallpaper => { if (!wallpaper?.url) return; const desktop = document.querySelector('#desktop'); desktop.style.backgroundImage = `linear-gradient(120deg,color-mix(in srgb,var(--bg) 68%,transparent),color-mix(in srgb,var(--bg) 36%,transparent)),url('${wallpaper.url}')`; desktop.style.backgroundSize = 'cover'; desktop.style.backgroundPosition = 'center'; desktop.title = t('shell.wallpaperTitle', { title: wallpaper.title || t('wallpaper.dailyImage') }); };
window.today = container => {
const entryDate = todayTargetDate || day(), memory = mems().find(item => item.date === entryDate);
@@ -109,8 +109,8 @@
};
window.saveProfile = () => { save('lifeos_profile', { name: pname.value, birthday: pbday.value, startDate: pstart.value || day(), motto: pmotto.value }); renderAll(); toast(t('profile.saved')); };
window.systemApp = container => {
- const state = settings(), preference = LifeI18n.preference(), bytes = new Blob([JSON.stringify({ mems: mems(), museum: museum(), profile: profile(), system: sys() })]).size, oldBackup = state.lastBackup && Date.now() - new Date(state.lastBackup) > 12096e5, detected = browserLanguage() === 'zh' ? t('system.detectedZh') : t('system.detectedEn');
- container.innerHTML = `${t('system.eyebrow')}
${t('system.title')}
${t('system.appearance')}
${['dark','light','auto'].map(item => ``).join('')}
${t('system.language')}
${detected}
${window.LifeOSNotifications?.markup() || ''}${t('system.storage')}
${t('system.local')}
${t('system.storageSummary',{memories:LifeI18n.number(mems().length),museum:LifeI18n.number(museum().length),tasks:LifeI18n.number(sys().tasksBoard?.tasks?.length||0),size:LifeI18n.number(Math.ceil(bytes/1024))})}
${t('system.dataStorage')}
${t('system.stored')}
${t('system.backups')}
${t('system.lastBackup',{value:state.lastBackup ? short(state.lastBackup.slice(0,10)) : t('common.never')})}${oldBackup ? `
${t('system.recommended')}` : ''}
${t('system.version')}
${t('system.release',{version:window.LifeOSVersion||'1.16.0'})}
`;
+ const state = settings(), preference = LifeI18n.preference(), bytes = new Blob([JSON.stringify({ mems: mems(), museum: museum(), profile: profile(), system: sys() })]).size, oldBackup = state.lastBackup && Date.now() - new Date(state.lastBackup) > 12096e5, detected = t('system.browserDetected', { name: LifeI18n.names[LifeI18n.browserLanguage()] });
+ container.innerHTML = `${t('system.eyebrow')}
${t('system.title')}
${t('system.appearance')}
${['dark','light','auto'].map(item => ``).join('')}
${t('system.language')}
${detected}
${LifeI18n.supported.map(code => ``).join('')}
${window.LifeOSNotifications?.markup() || ''}${t('system.storage')}
${t('system.local')}
${t('system.storageSummary',{memories:LifeI18n.number(mems().length),museum:LifeI18n.number(museum().length),tasks:LifeI18n.number(sys().tasksBoard?.tasks?.length||0),size:LifeI18n.number(Math.ceil(bytes/1024))})}
${t('system.dataStorage')}
${t('system.stored')}
${t('system.backups')}
${t('system.lastBackup',{value:state.lastBackup ? short(state.lastBackup.slice(0,10)) : t('common.never')})}${oldBackup ? `
${t('system.recommended')}` : ''}
${t('system.version')}
${t('system.release',{version:window.LifeOSVersion||'1.17.0'})}
`;
};
window.importPreview = () => { const data = importData, board = data.system?.tasksBoard || {}; rootModal().innerHTML = `${t('dialog.backup')}
${t('dialog.ready')}
${t('dialog.exported',{date:short(data.exportedAt.slice(0,10))})}
${t('dialog.memories',{count:data.memories.length})}
${t('dialog.museumItems',{count:data.museum.length})}
${t('dialog.goals',{count:board.goals?.length || 0})}
${t('dialog.tasks',{count:board.tasks?.length || 0})}
`; };
window.replaceConfirm = () => { rootModal().innerHTML = `${t('dialog.final')}
${t('dialog.replaceQuestion')}
${t('dialog.replaceWarning')}
`; };
diff --git a/public/i18n.js b/public/i18n.js
index cc852a0..4ffad76 100644
--- a/public/i18n.js
+++ b/public/i18n.js
@@ -1,53 +1,57 @@
/* Translation lookup and locale-aware formatting. Copy lives in /locales/*.json. */
(() => {
const dictionaries = window.LifeOSLocales || {};
- const en = dictionaries.en || {};
- const zh = dictionaries['zh-CN'] || {};
+ const supported = ['en', 'zh-CN', 'zh-HK', 'zh-MO', 'zh-TW', 'ar', 'fr', 'ru', 'es', 'ja'];
+ const names = { en: 'English', 'zh-CN': '简体中文', 'zh-HK': '繁體中文(香港)', 'zh-MO': '繁體中文(澳門)', 'zh-TW': '繁體中文(臺灣)', ar: 'العربية', fr: 'Français', ru: 'Русский', es: 'Español', ja: '日本語' };
const PREFERENCE_KEY = 'lifeos_language_preference_v1';
- const validPreference = value => ['auto', 'en', 'zh'].includes(value);
+ const normalize = value => {
+ const code = String(value || '').replace(/_/g, '-').toLowerCase();
+ if (code === 'zh' || code.startsWith('zh-hans') || code.startsWith('zh-cn') || code.startsWith('zh-sg')) return 'zh-CN';
+ if (code.startsWith('zh-hk') || code.startsWith('zh-hant-hk')) return 'zh-HK';
+ if (code.startsWith('zh-mo') || code.startsWith('zh-hant-mo')) return 'zh-MO';
+ if (code.startsWith('zh-tw') || code.startsWith('zh-hant')) return 'zh-TW';
+ return supported.find(item => item === code || code.startsWith(item + '-')) || null;
+ };
+ const validPreference = value => value === 'auto' || Boolean(normalize(value));
const lookup = (object, path) => path.split('.').reduce((value, part) => value?.[part], object);
const detectBrowserLanguage = () => {
const preferences = navigator.languages?.length ? navigator.languages : [navigator.language || navigator.userLanguage || 'en'];
- return preferences.some(value => String(value).toLowerCase().startsWith('zh')) ? 'zh' : 'en';
+ return preferences.map(normalize).find(Boolean) || 'en';
};
const readPreference = () => {
try {
const saved = localStorage.getItem(PREFERENCE_KEY);
- if (validPreference(saved)) return saved;
+ if (validPreference(saved)) return saved === 'auto' ? 'auto' : normalize(saved);
let oldSettings = {};
try { oldSettings = JSON.parse(localStorage.getItem('lifeos_settings') || '{}'); } catch {}
const legacyMode = oldSettings.languageMode ?? (['en', 'zh'].includes(oldSettings.language) ? 'manual' : 'auto');
- const migrated = legacyMode === 'manual' && ['en', 'zh'].includes(oldSettings.language) ? oldSettings.language : 'auto';
+ const migrated = legacyMode === 'manual' && normalize(oldSettings.language) ? normalize(oldSettings.language) : 'auto';
localStorage.setItem(PREFERENCE_KEY, migrated);
return migrated;
} catch { return 'auto'; }
};
let preference = readPreference();
const language = () => preference === 'auto' ? detectBrowserLanguage() : preference;
-
function t(key, values = {}) {
- const selected = language() === 'zh' ? zh : en;
- const template = lookup(selected, key) ?? lookup(en, key) ?? key;
+ const template = lookup(dictionaries[language()], key) ?? lookup(dictionaries.en, key) ?? key;
return String(template).replace(/\{(\w+)\}/g, (_, name) => values[name] ?? `{${name}}`);
}
-
- function locale() { return language() === 'zh' ? 'zh-CN' : 'en-US'; }
+ const locale = () => language() === 'en' ? 'en-US' : language();
function date(value, options) {
const parsed = value instanceof Date ? value : new Date(typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T12:00:00` : value);
return new Intl.DateTimeFormat(locale(), options).format(parsed);
}
- function number(value, options) { return new Intl.NumberFormat(locale(), options).format(value); }
+ const number = (value, options) => new Intl.NumberFormat(locale(), options).format(value);
function setLanguage(value) {
if (!validPreference(value)) return;
- preference = value;
- try { localStorage.setItem(PREFERENCE_KEY, value); } catch {}
+ preference = value === 'auto' ? 'auto' : normalize(value);
+ try { localStorage.setItem(PREFERENCE_KEY, preference); } catch {}
window.renderIcons?.();
window.renderAll?.();
window.applyLanguage?.();
- window.toast?.(value === 'auto' ? (language() === 'zh' ? 'feedback.followingZh' : 'feedback.followingEn') : (value === 'zh' ? 'feedback.languageZh' : 'feedback.languageEn'));
+ window.toast?.(preference === 'auto' ? t('feedback.followingBrowser', { name: names[language()] }) : t('feedback.languageSelected', { name: names[language()] }));
}
-
window.lang = language;
window.setLanguage = setLanguage;
- window.LifeI18n = { t, date, number, locale, language, preference: () => preference, dictionaries: { en, zh } };
+ window.LifeI18n = { t, date, number, locale, language, preference: () => preference, browserLanguage: detectBrowserLanguage, names, supported, dictionaries: { ...dictionaries, zh: dictionaries['zh-CN'] } };
})();
diff --git a/public/index.html b/public/index.html
index 126ddbb..cdacb57 100644
--- a/public/index.html
+++ b/public/index.html
@@ -10,19 +10,19 @@ document.head.insertAdjacentHTML('beforeend','LIFE OS
${text.title}
${escape(text.description)}
${escape(text.continue)}${escape(text.footnote)}