feat(i18n): add Traditional Chinese (zh-TW) locale

Adds full Traditional Chinese translation (628 keys, 100% parity with
en.json) and the registration plumbing to make it pickable in the UI.

Registration spans three layers that all needed updating for the locale
to actually be selectable end-to-end:
- static/locales/zh-TW.json (new) — TW vocabulary (儲存/雲端/檔案/偵測),
  uses 「」 corner brackets for in-string quoting
- core/i18n.js: add 'zh-TW' to supportedLocales — without this,
  setLocale('zh-TW') was silently rejected by the whitelist and the
  previous locale stayed active (visible as the "picked 繁中 but the
  setup step still shows 簡中" bug)
- core/languageSelector.js: add 'zh-TW' to AVAILABLE_LOCALES + fallback
- features/auth/auth.js: add 'zh-TW' to ALL_LANGUAGES (🇹🇼 繁體中文)
  and LANGUAGE_TEXTS bootstrap table (used before i18n loads)

Browser detection rewrite (i18n.js + auth.js detectBrowserLanguage):
The previous navigator.language?.substring(0, 2) truncated zh-TW → zh
and routed Traditional Chinese browsers to Simplified. Replaced with
three-tier matching: exact full-tag > Chinese script/region heuristic
(zh-Hant*, zh-{TW,HK,MO}) > primary subtag fallback.

Disambiguates the existing zh entry: "Chinese / 中文" became
"Simplified Chinese / 简体中文".

Drive-by cleanups discovered while wiring up the above:
- Remove dead t() in i18n.js (export uses safeT, no callers of bare t)
- Remove dead fetchUserData() and logout() in auth.js (userMenu.js has
  its own local logout())
- Extract errMessage(unknown→string) and inputVal(id) helpers for the
  catch sites and getElementById('x').value sites that needed TS
  narrowing under checkJs
- Type-annotate module-scope let forms/errors/panels with
  HTMLFormElement and HTMLElement so .addEventListener and .reset()
  resolve under strict
- Drop navigator.userLanguage IE legacy fallback (DOM lib has no field)
- jsconfig.json: drop exactOptionalPropertyTypes (only valid with
  strictNullChecks, which the project deliberately disables)
- .gitignore: ignore docker-compose.override.yml for local bind-mount
  dev workflow

Verified clean before commit: biome ci, tsc --noEmit, i18n key parity
(628/628), HTTP smoke test against running container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
tw199501
2026-05-16 04:59:53 +08:00
parent 8e6dc32baf
commit a142d52d05
6 changed files with 817 additions and 160 deletions
+3
View File
@@ -60,6 +60,9 @@ npm-debug.log
.env.test.local
.env.production.local
# Docker Compose local overrides (e.g. dev-only bind mounts)
docker-compose.override.yml
# Log files
*.log
logs/
-1
View File
@@ -9,7 +9,6 @@
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": true,
"lib": ["ES2022", "DOM"],
// Treat all JS files as modules
"moduleDetection": "force",
+33 -86
View File
@@ -5,18 +5,37 @@
* It loads translations from the server and provides functions to translate keys.
*/
// Current locale code (default to browser locale if available, fallback to English)
let currentLocale = navigator.language?.substring(0, 2) || navigator.userLanguage?.substring(0, 2) || 'en';
// Supported locales (languages that have locale files on the server)
// When a locale file is not found, the system gracefully falls back to English
const supportedLocales = ['en', 'es', 'zh', 'fa', 'fr', 'de', 'pt', 'nl', 'it', 'hi', 'ar', 'ru', 'ja', 'ko', 'pl'];
// Keep in sync with AVAILABLE_LOCALES in core/languageSelector.js
const supportedLocales = ['en', 'es', 'zh', 'zh-TW', 'fa', 'fr', 'de', 'pt', 'nl', 'it', 'hi', 'ar', 'ru', 'ja', 'ko', 'pl'];
// Fallback to English if locale is not supported
if (!supportedLocales.includes(currentLocale)) {
currentLocale = 'en';
// Resolve the best supported locale from a browser language list.
// Priority: exact full-tag (zh-TW) > Chinese script/region heuristics > primary subtag (zh)
function resolveBrowserLocale() {
const browserLangs = navigator.languages || [navigator.language || 'en'];
const lowerSupported = supportedLocales.map((l) => l.toLowerCase());
for (const bl of browserLangs) {
const idx = lowerSupported.indexOf(bl.toLowerCase());
if (idx !== -1) return supportedLocales[idx];
}
for (const bl of browserLangs) {
const tag = bl.toLowerCase();
if (!tag.startsWith('zh')) continue;
const isTraditional = tag.includes('hant') || /\b(tw|hk|mo)\b/.test(tag);
const target = isTraditional ? 'zh-TW' : 'zh';
if (supportedLocales.includes(target)) return target;
}
for (const bl of browserLangs) {
const primary = bl.substring(0, 2).toLowerCase();
if (supportedLocales.includes(primary)) return primary;
}
return 'en';
}
// Current locale code (overridden by saved preference in initI18n)
let currentLocale = resolveBrowserLocale();
// Cache for translations
/** @type {Record<string, Object>} */
const translations = {};
@@ -88,82 +107,6 @@ function getNestedValue(obj, path) {
return typeof current === 'string' ? current : null;
}
/**
* Translate a key to the current locale
* @param {string} key - The translation key (dot notation, e.g., 'app.title')
* @param {object} params - Parameters to replace in the translation (e.g., {name: 'John'})
* @returns {string} - The translated string or the key itself if not found
*/
// biome-ignore lint/correctness/noUnusedVariables: global function
function t(key, params = {}) {
// Get translation from cache
const localeData = translations[currentLocale];
if (!localeData) {
// Translation not loaded yet, return key
console.warn(`Translations for ${currentLocale} not loaded yet`);
return key;
}
// Special handling for shared_ and share_ prefixed keys
if (key.startsWith('shared_') || key.startsWith('share_')) {
const unprefixedKey = key.replace(/^(shared|share)_/, '');
const prefixObj = key.startsWith('shared_') ? localeData.shared : localeData.share;
if (prefixObj && typeof prefixObj === 'object' && unprefixedKey in prefixObj) {
return interpolate(prefixObj[unprefixedKey], params);
}
}
// Get the translation value
let value = getNestedValue(localeData, key);
// Compatibility aliases for legacy share.* keys used in some views
if (!value) {
const aliasMap = {
'share.enablePassword': 'share.password',
'share.enableExpiration': 'share.expiration',
'share.notifyEmail': 'share.notifyEmailLabel',
'share.notifyMessage': 'share.notifyMessageLabel'
};
const aliasKey = aliasMap[key];
if (aliasKey) {
value = getNestedValue(localeData, aliasKey);
}
}
if (!value) {
// Try fallback to English
if (currentLocale !== 'en' && translations.en) {
let fallbackValue = getNestedValue(translations.en, key);
if (!fallbackValue) {
const aliasMap = {
'share.enablePassword': 'share.password',
'share.enableExpiration': 'share.expiration',
'share.notifyEmail': 'share.notifyEmailLabel',
'share.notifyMessage': 'share.notifyMessageLabel'
};
const aliasKey = aliasMap[key];
if (aliasKey) {
fallbackValue = getNestedValue(translations.en, aliasKey);
}
}
if (fallbackValue) {
return interpolate(fallbackValue, params);
}
}
// Key not found, return key
console.warn(`Translation key not found: ${key}`);
return key;
}
// Replace parameters
return interpolate(value, params);
}
/**
* Replace parameters in a translation string
* @param {string} text - The translation string with placeholders
@@ -259,12 +202,16 @@ function translateElement(root) {
el.querySelectorAll('[data-i18n-placeholder]').forEach((element) => {
const key = element.getAttribute('data-i18n-placeholder');
element.placeholder = resolve(key);
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
element.placeholder = resolve(key);
}
});
el.querySelectorAll('[data-i18n-title]').forEach((element) => {
const key = element.getAttribute('data-i18n-title');
element.title = resolve(key);
if (element instanceof HTMLElement) {
element.title = resolve(key);
}
});
}
+5 -4
View File
@@ -8,7 +8,7 @@ import { i18n } from './i18n.js';
// Locale files that actually exist (have full translations)
// Keep this list in sync when adding new locale JSON files
const AVAILABLE_LOCALES = new Set(['en', 'es', 'zh', 'fa', 'fr', 'de', 'pt', 'it', 'nl', 'hi', 'ar', 'ru', 'ja', 'ko', 'pl']);
const AVAILABLE_LOCALES = new Set(['en', 'es', 'zh', 'zh-TW', 'fa', 'fr', 'de', 'pt', 'it', 'nl', 'hi', 'ar', 'ru', 'ja', 'ko', 'pl']);
// Language codes, names, and flag emojis
// Only returns languages that have a real locale file
@@ -19,7 +19,8 @@ function getAvailableLanguages() {
return [
{ code: 'en', name: 'English', flag: '🇬🇧' },
{ code: 'es', name: 'Español', flag: '🇪🇸' },
{ code: 'zh', name: '中文', flag: '🇨🇳' },
{ code: 'zh', name: '简体中文', flag: '🇨🇳' },
{ code: 'zh-TW', name: '繁體中文', flag: '🇹🇼' },
{ code: 'fa', name: 'فارسی', flag: '🇮🇷' },
{ code: 'fr', name: 'Français', flag: '🇫🇷' },
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
@@ -140,8 +141,8 @@ function createLanguageSelector(containerId = 'language-selector') {
});
// Listen for locale changes from i18n system
window.addEventListener('localeChanged', (e) => {
updateSelectedLanguage(e.detail.locale, container);
window.addEventListener('localeChanged', (/** @type {CustomEventInit<{locale: string}>} */ e) => {
if (e.detail) updateSelectedLanguage(e.detail.locale, container);
});
return container;
+90 -69
View File
@@ -19,6 +19,27 @@ const USER_DATA_KEY = 'oxicloud_user';
const LOCALE_KEY = 'oxicloud-locale';
const FIRST_RUN_KEY = 'oxicloud_first_run_completed';
/**
* Narrow a thrown value (TS unknown) to a displayable message string.
* Returns '' for non-Error throws so callers can fall back via `errMessage(e) || fallback`.
* @param {unknown} e
* @returns {string}
*/
function errMessage(e) {
return e instanceof Error ? e.message : '';
}
/**
* Read the value of an <input>/<textarea> by ID. Returns '' if missing.
* Centralises the HTMLInputElement cast we'd otherwise repeat at every callsite.
* @param {string} id
* @returns {string}
*/
function inputVal(id) {
const el = /** @type {HTMLInputElement | HTMLTextAreaElement | null} */ (document.getElementById(id));
return el?.value ?? '';
}
// Language selector texts (used before i18n is loaded)
const LANGUAGE_TEXTS = {
en: {
@@ -48,6 +69,15 @@ const LANGUAGE_TEXTS = {
modalTitle: '选择语言',
searchPlaceholder: '搜索语言...'
},
'zh-TW': {
title: '歡迎!',
subtitle: '選擇您的語言以繼續',
continue: '繼續',
autodetected: '我們偵測到您的語言',
moreLanguages: '更多語言...',
modalTitle: '選擇語言',
searchPlaceholder: '搜尋語言...'
},
fa: {
title: '!خوش آمدید',
subtitle: 'زبان خود را برای ادامه انتخاب کنید',
@@ -132,11 +162,18 @@ export const ALL_LANGUAGES = [
},
{
code: 'zh',
name: 'Chinese',
nativeName: '中文',
name: 'Simplified Chinese',
nativeName: '简体中文',
flag: '🇨🇳',
popular: true
},
{
code: 'zh-TW',
name: 'Traditional Chinese',
nativeName: '繁體中文',
flag: '🇹🇼',
popular: true
},
{
code: 'fa',
name: 'Persian',
@@ -368,13 +405,32 @@ async function checkSystemStatus() {
}
// Detect user's browser language and return the best matching language from ALL_LANGUAGES
// Priority: exact full-tag (zh-TW) > Chinese script/region heuristics > primary subtag (zh)
function detectBrowserLanguage() {
const browserLangs = navigator.languages || [navigator.language || navigator.userLanguage || 'en'];
const browserLangs = navigator.languages || [navigator.language || 'en'];
for (const bl of browserLangs) {
const code = bl.substring(0, 2).toLowerCase();
const match = ALL_LANGUAGES.find((l) => l.code === code);
const tag = bl.toLowerCase();
const exact = ALL_LANGUAGES.find((l) => l.code.toLowerCase() === tag);
if (exact) return exact;
}
// Chrome on macOS/Linux may report "zh-Hant", "zh-Hant-TW", "zh-Hans" without a plain region tag
for (const bl of browserLangs) {
const tag = bl.toLowerCase();
if (!tag.startsWith('zh')) continue;
const isTraditional = tag.includes('hant') || /\b(tw|hk|mo)\b/.test(tag);
const target = isTraditional ? 'zh-TW' : 'zh';
const match = ALL_LANGUAGES.find((l) => l.code === target);
if (match) return match;
}
for (const bl of browserLangs) {
const primary = bl.substring(0, 2).toLowerCase();
const match = ALL_LANGUAGES.find((l) => l.code === primary);
if (match) return match;
}
return ALL_LANGUAGES[0]; // fallback to English
}
@@ -403,7 +459,7 @@ function initLanguageSelector() {
const pickerList = document.getElementById('lang-picker-list');
const pickerFlag = document.getElementById('lang-picker-flag');
const pickerName = document.getElementById('lang-picker-name');
const searchInput = document.getElementById('lang-picker-search-input');
const searchInput = /** @type {HTMLInputElement | null} */ (document.getElementById('lang-picker-search-input'));
if (!languagePanel || !picker || !pickerFlag || !pickerName || !pickerList) return;
@@ -500,7 +556,7 @@ function initLanguageSelector() {
// Close when clicking outside
document.addEventListener('click', (e) => {
if (!picker.contains(e.target)) closePicker();
if (e.target instanceof Node && !picker.contains(e.target)) closePicker();
});
// --- Continue button ---
@@ -546,7 +602,7 @@ function updateLanguagePanelTexts(lang) {
const titleEl = document.getElementById('language-title');
const subtitleEl = document.getElementById('language-subtitle');
const continueBtn = document.getElementById('language-continue');
const searchInput = document.getElementById('lang-picker-search-input');
const searchInput = /** @type {HTMLInputElement | null} */ (document.getElementById('lang-picker-search-input'));
if (titleEl) titleEl.textContent = texts.title;
if (subtitleEl) subtitleEl.textContent = texts.subtitle;
@@ -657,9 +713,16 @@ async function configureOidcLoginUI() {
}
// DOM elements
let loginPanel, registerPanel, adminSetupPanel;
let loginForm, registerForm, adminSetupForm;
let loginError, registerError, registerSuccess, adminSetupError;
/** @type {HTMLElement | null} */ let loginPanel = null;
/** @type {HTMLElement | null} */ let registerPanel = null;
/** @type {HTMLElement | null} */ let adminSetupPanel = null;
/** @type {HTMLFormElement | null} */ let loginForm = null;
/** @type {HTMLFormElement | null} */ let registerForm = null;
/** @type {HTMLFormElement | null} */ let adminSetupForm = null;
/** @type {HTMLElement | null} */ let loginError = null;
/** @type {HTMLElement | null} */ let registerError = null;
/** @type {HTMLElement | null} */ let registerSuccess = null;
/** @type {HTMLElement | null} */ let adminSetupError = null;
// Initialize DOM elements only if we're on the login page
function initLoginElements() {
@@ -673,9 +736,9 @@ function initLoginElements() {
registerPanel = document.getElementById('register-panel');
adminSetupPanel = document.getElementById('admin-setup-panel');
loginForm = document.getElementById('login-form');
registerForm = document.getElementById('register-form');
adminSetupForm = document.getElementById('admin-setup-form');
loginForm = /** @type {HTMLFormElement | null} */ (document.getElementById('login-form'));
registerForm = /** @type {HTMLFormElement | null} */ (document.getElementById('register-form'));
adminSetupForm = /** @type {HTMLFormElement | null} */ (document.getElementById('admin-setup-form'));
loginError = document.getElementById('login-error');
registerError = document.getElementById('register-error');
@@ -853,7 +916,7 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
} catch (err) {
console.log('Session probe failed, showing login page:', err.message);
console.log('Session probe failed, showing login page:', errMessage(err));
}
// No valid session — stay on login page
localStorage.removeItem(USER_DATA_KEY);
@@ -879,8 +942,8 @@ if (isLoginPage && loginForm) {
// Clear previous errors
loginError.style.display = 'none';
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
const username = inputVal('login-username');
const password = inputVal('login-password');
try {
const data = await login(username, password);
@@ -917,7 +980,7 @@ if (isLoginPage && loginForm) {
}
redirectToMainApp();
} catch (error) {
loginError.textContent = error.message || 'Error logging in';
loginError.textContent = errMessage(error) || 'Error logging in';
loginError.style.display = 'block';
}
});
@@ -932,10 +995,10 @@ if (isLoginPage && registerForm) {
registerError.style.display = 'none';
registerSuccess.style.display = 'none';
const username = document.getElementById('register-username').value;
const email = document.getElementById('register-email').value;
const password = document.getElementById('register-password').value;
const confirmPassword = document.getElementById('register-password-confirm').value;
const username = inputVal('register-username');
const email = inputVal('register-email');
const password = inputVal('register-password');
const confirmPassword = inputVal('register-password-confirm');
// Validate passwords match
if (password !== confirmPassword) {
@@ -959,7 +1022,7 @@ if (isLoginPage && registerForm) {
hidePanel(registerPanel);
}, 2000);
} catch (error) {
registerError.textContent = error.message || i18n.t('auth.admin_create_error');
registerError.textContent = errMessage(error) || i18n.t('auth.admin_create_error');
registerError.style.display = 'block';
}
});
@@ -975,9 +1038,9 @@ if (isLoginPage && adminSetupForm) {
const adminSetupSuccess = document.getElementById('admin-setup-success');
if (adminSetupSuccess) adminSetupSuccess.style.display = 'none';
const email = document.getElementById('admin-email').value;
const password = document.getElementById('admin-password').value;
const confirmPassword = document.getElementById('admin-password-confirm').value;
const email = inputVal('admin-email');
const password = inputVal('admin-password');
const confirmPassword = inputVal('admin-password-confirm');
// Validate passwords match
if (password !== confirmPassword) {
@@ -1016,7 +1079,7 @@ if (isLoginPage && adminSetupForm) {
if (adminSetupSuccess) adminSetupSuccess.style.display = 'none';
}, 2000);
} catch (error) {
adminSetupError.textContent = error.message || i18n.t('auth.admin_create_error');
adminSetupError.textContent = errMessage(error) || i18n.t('auth.admin_create_error');
adminSetupError.style.display = 'block';
}
});
@@ -1120,29 +1183,6 @@ async function register(username, email, password, role = 'user') {
}
}
/**
* Fetch current user data — relies on HttpOnly cookie (auto-sent).
*/
// biome-ignore lint/correctness/noUnusedVariables: global function
async function fetchUserData() {
try {
const response = await fetch(ME_ENDPOINT, {
method: 'GET',
credentials: 'same-origin'
});
if (!response.ok) {
throw new Error('Error fetching user data');
}
return await response.json();
} catch (error) {
console.error('Error fetching user data:', error);
throw error;
}
}
/**
* Refresh authentication token via the server's refresh endpoint.
* The refresh-token cookie is sent automatically (HttpOnly, Path=/api/auth).
@@ -1238,22 +1278,3 @@ function redirectToMainApp() {
}
}
/**
* Logout — tell the server to clear HttpOnly cookies, then redirect.
*/
// biome-ignore lint/correctness/noUnusedVariables: global function
async function logout() {
try {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
} catch (e) {
console.warn('Logout request failed:', e);
}
localStorage.removeItem(USER_DATA_KEY);
localStorage.removeItem('refresh_attempts');
window.location.href = '/login';
}
+686
View File
@@ -0,0 +1,686 @@
{
"app": {
"title": "OxiCloud",
"description": "極簡雲端儲存系統"
},
"nav": {
"files": "檔案",
"shared": "共享",
"recent": "最近",
"favorites": "收藏",
"photos": "照片",
"music": "音樂",
"trash": "回收站"
},
"photos": {
"empty_state": "還沒有照片",
"empty_hint": "上傳圖片或影片即可在此檢視",
"items_selected": "已選擇",
"view_daily": "日",
"view_monthly": "月",
"view_yearly": "年"
},
"music": {
"create_playlist": "建立播放列表",
"playlists": "播放列表",
"no_playlists": "還沒有播放列表",
"select_playlist": "選擇一個播放列表",
"select_hint": "從側邊欄選擇播放列表或建立新播放列表",
"add_tracks": "新增曲目",
"no_tracks": "此播放列表中沒有曲目",
"unknown_artist": "未知藝術家",
"unknown_title": "未知",
"confirm_delete": "刪除此播放列表?",
"playlist_name": "播放列表名稱",
"create": "建立",
"delete": "刪除",
"share": "分享",
"edit": "編輯",
"play_all": "全部播放",
"shuffle": "隨機播放",
"repeat": "重複",
"repeat_one": "單曲迴圈",
"queue": "播放佇列",
"queue_empty": "播放佇列為空",
"not_playing": "未播放",
"play": "播放",
"pause": "暫停",
"previous": "上一首",
"next": "下一首",
"volume": "音量",
"mute": "靜音",
"unmute": "取消靜音",
"title": "標題",
"artist": "藝術家",
"album": "專輯",
"tracks": "首曲目",
"add": "新增",
"added": "已新增!",
"added_to_playlist": "已新增到播放列表",
"add_to_playlist": "新增到播放列表",
"load_error": "載入播放列表出錯",
"add_error": "無法將曲目新增到播放列表",
"no_playlists_yet": "暫無播放列表。請先建立一個!",
"selected_files": "已選擇:",
"error": "錯誤",
"search_audio": "搜尋音訊檔案…",
"no_audio_files": "未找到音訊檔案",
"selected": "已選擇",
"loading": "載入中…",
"search_error": "無法載入音訊檔案",
"adding": "新增中…",
"can_write": "可以編輯",
"cover_updated": "封面已更新",
"empty_hint": "建立你的第一個播放列表來開始整理你的音樂",
"make_private": "設為私人",
"make_public": "設為公開",
"manage_shares": "管理共享",
"no_shares": "尚未共享",
"playback_error": "播放失敗",
"private": "私人",
"public": "公開",
"read_only": "唯讀",
"remove": "移除",
"remove_share": "移除共享",
"set_cover": "設定封面",
"share_with_user": "使用者 ID 或電子郵件",
"toggle_public": "可見性",
"track_removed": "曲目已移除"
},
"actions": {
"search": "搜尋檔案...",
"new_folder": "新建資料夾",
"upload": "上傳",
"upload_files": "上傳檔案",
"upload_folder": "上傳資料夾",
"upload.uploading": "上傳中...",
"upload.complete": "{count} / {total} 已上傳",
"upload.files": "檔案",
"rename": "重新命名",
"move": "移動到...",
"move_to": "移動到",
"delete": "刪除",
"download": "下載",
"view": "檢視",
"cancel": "取消",
"confirm": "確認",
"share": "共享",
"favorite": "新增到收藏",
"unfavorite": "取消收藏",
"copy": "複製",
"notify": "通知",
"send": "傳送",
"clear_recent": "清除最近",
"logout": "退出登入",
"create": "建立",
"search_btn": "搜尋",
"close": "關閉",
"delete_permanently": "永久刪除",
"empty_trash": "清空回收站",
"open_parent_folder": "轉到父資料夾"
},
"user_menu": {
"appearance": "外觀",
"about": "關於 OxiCloud",
"about_description": "基於 Rust 和整潔架構構建的雲端儲存平臺。快速、安全、私密。",
"admin_panel": "管理面板",
"profile": "我的資料",
"role_user": "使用者"
},
"share": {
"dialogTitle": "共享連結",
"linkLabel": "共享連結:",
"copyLink": "複製",
"permissions": "許可權:",
"permissionRead": "讀取",
"permissionWrite": "寫入",
"permissionReshare": "再共享",
"password": "密碼保護:",
"generatePassword": "生成",
"expiration": "過期日期:",
"update": "更新共享",
"remove": "移除共享",
"notifyTitle": "傳送通知",
"notifyEmailLabel": "電子郵件地址:",
"notifyMessageLabel": "訊息(可選):",
"notifySend": "傳送通知",
"shareWithOthers": "與他人共享",
"sharePublicly": "公開共享",
"shareSettings": "共享設定",
"shareCopied": "連結已複製到剪貼簿",
"shareCreated": "共享連結建立成功",
"shareUpdated": "共享設定更新成功",
"shareRemoved": "共享已移除"
},
"share_dialogTitle": "共享連結",
"share_linkLabel": "共享連結:",
"share_copyLink": "複製",
"share_permissions": "許可權:",
"share_permissionRead": "讀取",
"share_permissionWrite": "寫入",
"share_permissionReshare": "再共享",
"share_password": "密碼保護:",
"share_generatePassword": "生成",
"share_expiration": "過期日期:",
"share_update": "更新共享",
"share_remove": "移除共享",
"share_notifyTitle": "傳送通知",
"share_notifyEmailLabel": "電子郵件地址:",
"share_notifyMessageLabel": "訊息(可選):",
"share_notifySend": "傳送通知",
"shared": {
"backToFiles": "返回檔案",
"pageTitle": "共享資源",
"pageDescription": "管理你的共享檔案和資料夾",
"filterType": "型別:",
"filterAll": "全部",
"filterFiles": "檔案",
"filterFolders": "資料夾",
"sortBy": "排序依據:",
"sortByName": "名稱",
"sortByDate": "共享日期",
"sortByExpiration": "過期日期",
"search": "搜尋",
"colName": "名稱",
"colType": "型別",
"colDateShared": "共享日期",
"colExpiration": "過期日期",
"colPermissions": "許可權",
"colPassword": "密碼",
"colActions": "操作",
"emptyStateTitle": "尚未有共享資源",
"emptyStateDesc": "當你共享檔案或資料夾時,它們會出現在這裡",
"goToFiles": "前往檔案",
"typeFile": "檔案",
"typeFolder": "資料夾",
"noExpiration": "無過期",
"hasPassword": "有",
"noPassword": "無",
"editShare": "編輯共享",
"notifyShare": "通知某人",
"copyLink": "複製連結",
"removeShare": "移除共享",
"linkCopied": "連結已複製到剪貼簿!",
"linkCopyFailed": "複製連結失敗",
"itemUpdated": "共享設定更新成功",
"itemRemoved": "共享已移除成功",
"invalidEmail": "請輸入有效的電子郵件地址",
"notificationSent": "通知已成功傳送",
"notificationFailed": "傳送通知失敗",
"shared_backToFiles": "返回檔案",
"shared_colActions": "操作",
"shared_colDateShared": "共享日期",
"shared_colExpiration": "過期日期",
"shared_colName": "名稱",
"shared_colPassword": "密碼",
"shared_colPermissions": "許可權",
"shared_colType": "類型",
"shared_copyLink": "複製連結",
"shared_editShare": "編輯共享",
"shared_emptyStateDesc": "當你共享檔案或資料夾時,它們會顯示在此",
"shared_emptyStateTitle": "尚無共享資源",
"shared_filterAll": "全部",
"shared_filterFiles": "檔案",
"shared_filterFolders": "資料夾",
"shared_filterType": "類型:",
"shared_goToFiles": "前往檔案",
"shared_hasPassword": "是",
"shared_invalidEmail": "請輸入有效的電子郵件地址",
"shared_itemRemoved": "共享已成功移除",
"shared_itemUpdated": "共享設定已成功更新",
"shared_linkCopied": "連結已複製到剪貼簿!",
"shared_linkCopyFailed": "複製連結失敗",
"shared_noExpiration": "永不過期",
"shared_noPassword": "否",
"shared_notificationFailed": "傳送通知失敗",
"shared_notificationSent": "通知已成功傳送",
"shared_notifyShare": "通知對方",
"shared_pageDescription": "管理你的共享檔案與資料夾",
"shared_pageTitle": "共享資源",
"shared_removeShare": "移除共享",
"shared_search": "搜尋",
"shared_sortBy": "排序方式:",
"shared_sortByDate": "共享日期",
"shared_sortByExpiration": "過期日期",
"shared_sortByName": "名稱",
"shared_typeFile": "檔案",
"shared_typeFolder": "資料夾"
},
"files": {
"name": "名稱",
"type": "型別",
"size": "大小",
"modified": "修改日期",
"no_files": "此資料夾中沒有檔案",
"empty_hint": "上傳檔案或建立資料夾以開始使用",
"loading": "正在載入檔案…",
"view_grid": "網格檢視",
"view_list": "列表檢視",
"file_types": {
"document": "文件",
"image": "圖片",
"video": "影片",
"audio": "音訊",
"pdf": "PDF",
"text": "文字",
"folder": "資料夾",
"spreadsheet": "電子表格",
"presentation": "簡報",
"archive": "壓縮檔案",
"installer": "安裝程式",
"code": "程式碼"
}
},
"dialogs": {
"rename_folder": "重新命名資料夾",
"new_name": "新名稱",
"new_folder_title": "新建資料夾",
"folder_name": "資料夾名稱",
"folder_placeholder": "我的資料夾",
"rename_title": "重新命名",
"move_file": "移動檔案",
"select_destination": "選擇目標資料夾",
"root": "根目錄",
"delete_confirmation": "你確定要刪除",
"and_contents": "及其所有內容",
"no_undo": "此操作無法撤銷",
"share_file": "共享檔案",
"share_folder": "共享資料夾",
"existing_shares": "現有共享",
"share_options": "共享選項",
"password": "密碼",
"expiration": "過期日期",
"permissions": "許可權",
"generated_link": "生成的連結",
"notify": "傳送通知",
"recipient": "收件人",
"message": "訊息",
"confirm_delete": "移至回收站",
"confirm_delete_file": "確定要將檔案「{{name}}」移至回收站嗎?",
"confirm_delete_folder": "確定要將資料夾「{{name}}」及其所有內容移至回收站嗎?",
"confirm_delete_share": "刪除共享連結",
"confirm_delete_share_msg": "確定要刪除此共享連結嗎?",
"confirm_empty_trash": "清空回收站",
"confirm_permanent_delete": "永久刪除",
"confirm_permanent_delete_msg": "確定要永久刪除此項目嗎?此操作無法復原。",
"confirm_title": "確認操作",
"go_to_parent": ".. (上層資料夾)",
"move_folder": "移動資料夾",
"no_subfolders": "沒有子資料夾",
"rename_file": "重新命名檔案",
"select_this_folder": "選擇此資料夾",
"move_to_home": "移動到主資料夾"
},
"dropzone": {
"drag_files": "將檔案拖到這裡,或點選選擇",
"drop_files": "釋放檔案以上傳"
},
"permissions": {
"read": "讀取",
"write": "寫入",
"reshare": "再共享"
},
"errors": {
"file_not_found": "檔案未找到",
"folder_not_found": "資料夾未找到",
"delete_error": "刪除時出錯",
"upload_error": "上傳檔案時出錯",
"rename_error": "重新命名時出錯",
"move_error": "移動時出錯",
"empty_name": "名稱不能為空",
"name_exists": "已存在同名檔案或資料夾",
"generic_error": "發生錯誤"
},
"breadcrumb": {
"home": "主頁"
},
"trash": {
"empty_trash": "清空回收站",
"empty_state": "回收站為空",
"original_location": "原始位置",
"deleted_date": "刪除日期",
"actions": "操作",
"restore": "恢復",
"delete_permanently": "永久刪除",
"empty_confirm": "你確定要清空回收站嗎?這將永久刪除所有專案。"
},
"auth": {
"login_title": "登入",
"username": "使用者名稱",
"username_placeholder": "輸入你的使用者名稱",
"password": "密碼",
"password_placeholder": "輸入你的密碼",
"login_button": "登入",
"no_account": "沒有賬號?",
"register": "註冊",
"admin_setup": "首次使用?",
"setup": "設定管理員",
"register_title": "建立賬號",
"email": "電子郵件",
"email_placeholder": "輸入你的電子郵件",
"confirm_password": "確認密碼",
"confirm_password_placeholder": "確認你的密碼",
"register_button": "建立賬號",
"have_account": "已有賬號?",
"login": "登入",
"setup_title": "初始設定",
"setup_step1": "管理員",
"setup_step2": "系統",
"setup_step3": "完成",
"admin_username": "管理員使用者名稱",
"admin_email": "管理員電子郵件",
"admin_password": "管理員密碼",
"create_admin": "建立管理員",
"back_to_login": "已設定完成?",
"admin_success": "管理員賬號建立成功!您現在可以登入。",
"account_success": "賬號建立成功!您現在可以登入。",
"passwords_mismatch": "密碼不匹配",
"admin_create_error": "建立管理員賬號時出錯",
"or": "或",
"sso_login": "使用 SSO 登入",
"sso_login_provider": "使用 {{provider}} 登入"
},
"storage": {
"title": "儲存空間",
"calculating": "計算中...",
"used": "{{percentage}}% 已使用 ({{used}} / {{total}})"
},
"viewer": {
"unsupported_file": "無法預覽此檔案型別。",
"download_file": "下載檔案",
"zoom_in": "放大",
"zoom_out": "縮小",
"zoom_reset": "重置縮放"
},
"language_selector": {
"title": "歡迎!",
"subtitle": "選擇您的語言以繼續",
"continue": "繼續",
"languages": {
"en": "English",
"es": "Español",
"zh": "中文",
"fa": "فارسی",
"fr": "Français",
"de": "Deutsch",
"pt": "Português",
"ar": "العربية",
"hi": "हिन्दी",
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nl": "Nederlands",
"ru": "Русский"
}
},
"favorites": {
"empty_state": "還沒有收藏",
"empty_hint": "為檔案或資料夾新增星標以將其新增到收藏夾",
"add": "新增到收藏夾",
"remove": "從收藏夾移除",
"added_title": "已新增到收藏",
"added_msg": "已新增到收藏",
"removed_title": "已從收藏移除",
"removed_msg": "已從收藏移除"
},
"recent": {
"title": "最近",
"clear": "清除最近",
"accessed": "訪問於",
"empty_state": "沒有最近檔案",
"empty_hint": "您開啟的檔案將顯示在這裡"
},
"batch": {
"one_selected": "已選擇 1 個專案",
"n_selected": "已選擇 {{count}} 個專案",
"confirm_delete": "確定要將 {{count}} 個專案移至回收站嗎?",
"move_title": "移動 {{count}} 個專案",
"add_favorites": "新增到收藏夾",
"move_copy": "移動或複製"
},
"admin": {
"page_title": "管理面板",
"back_to_app": "返回 OxiCloud",
"loading": "載入中…",
"access_denied": "拒絕訪問",
"access_denied_desc": "需要管理員許可權。",
"sign_in": "登入",
"tab_dashboard": "儀表盤",
"tab_users": "使用者",
"tab_oidc": "SSO / OIDC",
"total_users": "使用者總數",
"active_users": "活躍使用者",
"admins": "管理員",
"version": "版本",
"storage_overview": "儲存概覽",
"used": "已使用",
"total_quota": "總配額",
"usage_pct": "使用率",
"users_over_80": "超過80%配額",
"users_over_quota": "超過配額",
"system": "系統",
"auth_label": "認證",
"oidc_label": "OIDC",
"quotas_label": "配額",
"enabled": "已啟用",
"disabled": "已禁用",
"active": "活躍",
"off": "關閉",
"allow_registration": "允許公開自助註冊",
"registration_warning": "公開註冊已禁用。只有管理員可以建立新使用者。",
"user_management": "使用者管理",
"create_user": "建立使用者",
"col_user": "使用者",
"col_role": "角色",
"col_auth": "認證",
"col_status": "狀態",
"col_storage": "儲存",
"col_last_login": "最後登入",
"col_actions": "操作",
"loading_users": "正在載入使用者…",
"failed_load_users": "載入失敗",
"no_users_found": "未找到使用者",
"showing_users": "顯示 {{from}}-{{to}} / {{total}}",
"prev": "上一頁",
"next": "下一頁",
"inactive": "未啟用",
"you_badge": "(你)",
"local": "本地",
"never": "從未",
"just_now": "剛剛",
"minutes_ago": "{{n}}分鐘前",
"hours_ago": "{{n}}小時前",
"days_ago": "{{n}}天前",
"edit_quota_title": "編輯配額",
"reset_password_title": "重置密碼",
"toggle_role_title": "切換角色",
"deactivate_title": "停用",
"activate_title": "啟用",
"delete_title": "刪除",
"sso_title": "單點登入 (OIDC / SSO)",
"enable_sso": "啟用 SSO 認證",
"provider_name": "提供商名稱",
"issuer_url": "發行者 URL",
"issuer_url_hint": "您的身份提供商的 OpenID Connect 發行者 URL",
"auto_discover": "自動發現",
"discovering": "發現中…",
"client_id": "客戶端 ID",
"client_secret": "客戶端金鑰",
"client_secret_placeholder": "留空以保留當前值",
"secret_configured": "已配置客戶端金鑰",
"callback_url": "回撥 URL",
"callback_url_hint": "(在您的 IdP 中註冊)",
"advanced_settings": "高階設定",
"scopes": "範圍",
"auto_provision": "首次登入時自動配置使用者",
"admin_groups": "管理組",
"admin_groups_hint": "對映到管理員角色的逗號分隔 OIDC 組名",
"disable_password": "禁用密碼登入 (僅 OIDC)",
"password_warning": "這將阻止所有基於密碼的登入!",
"test_btn": "測試",
"save_btn": "儲存",
"saving": "儲存中…",
"settings_saved": "設定已儲存 — OIDC 現在 {{status}}",
"quota_modal_title": "更新儲存配額",
"quota_user_label": "使用者:",
"new_quota": "新配額",
"quota_unlimited_hint": "0表示無限制",
"cancel": "取消",
"create_user_title": "建立新使用者",
"username_label": "使用者名稱",
"username_placeholder": "zhangsan",
"username_hint": "3–32個字元",
"password_label": "密碼",
"password_placeholder": "至少8個字元",
"email_label": "郵箱",
"email_optional": "(可選)",
"email_placeholder": "user@example.com (留空自動生成)",
"role_label": "角色",
"role_user": "使用者",
"role_admin": "管理員",
"quota_label": "配額",
"creating": "建立中…",
"reset_pw_title": "重置密碼",
"new_password_label": "新密碼",
"resetting": "重置中…",
"reset_btn": "重置",
"confirm_role_change": "將角色更改為 {{role}}?",
"confirm_deactivate": "確定要停用此使用者嗎?",
"confirm_activate": "確定要啟用此使用者嗎?",
"confirm_delete_user": "刪除使用者 \"{{name}}\"?此操作無法撤消!",
"confirm_action": "確認操作",
"confirm_yes": "確認",
"confirm_no": "取消",
"error_username_short": "使用者名稱至少需要3個字元",
"error_password_short": "密碼至少需要8個字元",
"error_generic": "失敗",
"error_network": "網路錯誤:{{message}}",
"error_create_user": "建立使用者失敗",
"tab_storage": "儲存",
"storage_title": "儲存配置",
"storage_current_backend": "當前後端",
"storage_total_blobs": "總塊數",
"storage_total_size": "總大小",
"storage_dedup_ratio": "去重比率",
"storage_backend": "後端",
"storage_local": "本地",
"storage_s3": "S3 相容",
"storage_provider_preset": "提供商預設",
"storage_preset_custom": "自定義",
"storage_endpoint_url": "端點 URL",
"storage_endpoint_hint": "AWS S3 請留空",
"storage_bucket": "儲存桶",
"storage_region": "地區",
"storage_access_key": "訪問金鑰",
"storage_secret_key": "金鑰",
"storage_secret_configured": "金鑰已配置",
"storage_key_placeholder": "輸入新金鑰",
"storage_path_style": "強制路徑風格",
"storage_path_style_hint": "MinIO 及某些 S3 相容服務需要此選項",
"storage_test_connection": "測試連線",
"storage_test_success": "連線成功",
"storage_test_failure": "連線失敗",
"storage_save": "儲存配置",
"storage_saved": "配置已儲存",
"storage_migration": "資料遷移",
"storage_migration_coming_soon": "遷移工具即將推出",
"migration_status_label": "遷移狀態",
"migration_start": "開始遷移",
"migration_pause": "暫停",
"migration_resume": "繼續",
"migration_verify": "驗證",
"migration_complete": "完成",
"migration_started": "遷移已開始",
"migration_paused_msg": "遷移已暫停",
"migration_resumed_msg": "遷移已繼續",
"migration_completed_msg": "遷移成功完成",
"migration_verifying": "正在驗證...",
"migration_verify_passed": "驗證透過",
"migration_verify_failed": "驗證失敗",
"migration_failed_blobs": "失敗的塊",
"testing": "正在測試..."
},
"profile": {
"page_title": "個人資料",
"back_to_app": "返回 OxiCloud",
"loading": "載入中…",
"not_authenticated": "未認證",
"not_authenticated_desc": "請登入以檢視您的個人資料。",
"sign_in": "登入",
"role_admin": "管理員",
"role_user": "使用者",
"account_details": "賬戶詳情",
"username": "使用者名稱",
"email": "郵箱",
"role": "角色",
"last_login": "最後登入",
"storage": "儲存",
"used": "已使用",
"quota": "配額",
"usage": "使用率",
"unlimited": "無限制",
"app_passwords": "應用密碼",
"app_pw_desc": "為 WebDAV、CalDAV 和 CardDAV 客戶端生成密碼。每個密碼只顯示一次。",
"app_pw_label_placeholder": "標籤(如 Thunderbird、macOS)",
"generate": "生成",
"generating": "生成中…",
"new_password_for": "新密碼用於",
"copy_warning": "請立即複製此密碼,之後將無法再次檢視。",
"copy_to_clipboard": "複製到剪貼簿",
"col_label": "標籤",
"col_created": "建立時間",
"col_last_used": "最後使用",
"col_status": "狀態",
"active": "活躍",
"revoked": "已撤銷",
"revoke_title": "撤銷",
"no_app_passwords": "暫無應用密碼。",
"client_sessions": "客戶端會話",
"client_sessions_desc": "連線 Nextcloud 相容客戶端時自動生成。",
"col_client": "客戶端",
"never": "從未",
"just_now": "剛剛",
"minutes_ago": "{{n}}分鐘前",
"hours_ago": "{{n}}小時前",
"days_ago": "{{n}}天前",
"change_password": "修改密碼",
"current_password": "當前密碼",
"new_password": "新密碼",
"min_8_chars": "至少8個字元",
"confirm_password": "確認新密碼",
"update_password": "更新密碼",
"updating": "更新中…",
"password_updated": "密碼更新成功",
"passwords_no_match": "密碼不匹配",
"password_too_short": "密碼至少需要8個字元",
"password_change_failed": "修改密碼失敗",
"error_network": "網路錯誤:{{message}}",
"error_label_required": "請輸入標籤",
"error_create_pw": "建立應用密碼失敗",
"confirm_revoke": "撤銷應用密碼\"{{label}}\"?使用此密碼的客戶端將停止工作。",
"error_revoke": "撤銷失敗"
},
"notifications": {
"file_renamed": "檔案已重新命名",
"file_renamed_to": "檔案已重新命名為\"{{name}}\"",
"folder_renamed": "資料夾已重新命名",
"folder_renamed_to": "資料夾已重新命名為\"{{name}}\"",
"file_uploaded": "檔案已上傳",
"file_deleted": "檔案已移至回收站",
"folder_deleted": "資料夾已移至回收站",
"item_deleted_permanently": "專案已永久刪除",
"trash_emptied": "回收站已清空",
"title": "通知",
"empty": "暫無通知",
"link_created": "連結已建立",
"share_success": "分享連結建立成功",
"upload_files_section_title": "此處不支援上傳",
"upload_files_section_body": "請前往檔案部分上傳檔案"
},
"upload": {
"uploading": "正在上傳...",
"files": "個檔案",
"complete": "已上傳 {{count}} / {{total}}"
},
"storage_quota_exceeded": "儲存配額已超限"
}