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:
@@ -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/
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"lib": ["ES2022", "DOM"],
|
||||
// Treat all JS files as modules
|
||||
"moduleDetection": "force",
|
||||
|
||||
+31
-84
@@ -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');
|
||||
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');
|
||||
if (element instanceof HTMLElement) {
|
||||
element.title = resolve(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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": "儲存配額已超限"
|
||||
}
|
||||
Reference in New Issue
Block a user