add: 添加 i18n 翻译

This commit is contained in:
ci
2026-06-19 13:05:35 +08:00
parent 5ebe562a62
commit 73dfe12c95
8 changed files with 654 additions and 189 deletions
+45 -50
View File
@@ -11,6 +11,7 @@
import { post } from "../rc.js";
import { getState, setState, toast } from "../state.js";
import { t } from "../i18n.js";
// --- Route entrypoints ---
@@ -23,9 +24,9 @@ export async function renderConfigureNew({ provider = "" }) {
if (!info) {
document.getElementById("app").innerHTML = `
<div class="empty">
<h3>Unknown backend</h3>
<p>No provider named <code>${escapeHtml(provider)}</code>.</p>
<p><a href="#/configure/new">← Back to provider picker</a></p>
<h3>${t("error.unknown_remote")}</h3>
<p>${t("error.no_such_provider", provider)}</p>
<p><a href="#/configure/new">← ${t("configure.new_title")}</a></p>
</div>`;
return;
}
@@ -38,23 +39,22 @@ export async function renderConfigureEdit({ remote }) {
return;
}
const app = document.getElementById("app");
app.innerHTML = `<div class="empty"><p>Loading remote <code>${escapeHtml(remote)}</code>…</p></div>`;
app.innerHTML = `<div class="empty"><p>${t("loading.title")}…</p></div>`;
// Find the remote's type from /config/dump
let typeName;
try {
const dump = await post("config/dump");
typeName = dump && dump[remote] && dump[remote].type;
} catch (e) {
toast(`Couldn't read remote: ${e.message}`, "error");
toast(`${t("error.couldnt_load")}: ${e.message}`, "error");
return;
}
if (!typeName) {
app.innerHTML = `
<div class="empty">
<h3>Remote not found</h3>
<p>No remote named <code>${escapeHtml(remote)}</code>.</p>
<p><a href="#/remotes">← Back to remotes</a></p>
<h3>${t("error.unknown_remote")}</h3>
<p>${t("error.no_such_remote", remote)}</p>
<p><a href="#/remotes">← ${t("nav.remotes")}</a></p>
</div>`;
return;
}
@@ -62,7 +62,7 @@ export async function renderConfigureEdit({ remote }) {
const providers = await ensureProviders();
const info = providers.find((p) => p.Name === typeName);
if (!info) {
toast(`Backend ${typeName} not found in registry`, "error");
toast(`${t("error.no_such_provider", typeName)}`, "error");
return;
}
@@ -70,7 +70,7 @@ export async function renderConfigureEdit({ remote }) {
try {
currentValues = await post("config/get", { name: remote });
} catch (e) {
toast(`Couldn't read config: ${e.message}`, "error");
toast(`${t("error.couldnt_load")}: ${e.message}`, "error");
}
return renderForm({
@@ -88,16 +88,16 @@ async function renderProviderPicker() {
app.innerHTML = `
<div class="section-head">
<div>
<h2>New remote</h2>
<p class="subtitle">Pick a storage backend to configure.</p>
<h2>${t("configure.new_title")}</h2>
<p class="subtitle">${t("configure.new_subtitle")}</p>
</div>
<a class="btn btn-secondary btn-sm" href="#/remotes">Cancel</a>
<a class="btn btn-secondary btn-sm" href="#/remotes">${t("configure.cancel")}</a>
</div>
<div class="provider-search">
<input id="provider-filter" class="input" type="search" placeholder="Filter backends (e.g. s3, sftp, local)…" autocomplete="off">
<input id="provider-filter" class="input" type="search" placeholder="${t("configure.filter_placeholder")}" autocomplete="off">
</div>
<div id="provider-grid" class="connector-grid">
<p class="empty" style="grid-column:1/-1">Loading backends…</p>
<p class="empty" style="grid-column:1/-1">${t("loading.title")}…</p>
</div>
`;
@@ -108,18 +108,17 @@ async function renderProviderPicker() {
try {
providers = await ensureProviders();
} catch (e) {
grid.innerHTML = `<div class="empty" style="grid-column:1/-1"><h3>Couldnt load backends</h3><p>${escapeHtml(e.message)}</p></div>`;
grid.innerHTML = `<div class="empty" style="grid-column:1/-1"><h3>${t("error.couldnt_load_backends")}</h3><p>${escapeHtml(e.message)}</p></div>`;
return;
}
// Skip hidden + alias/all wrapper backends
const visible = providers.filter(
(p) => !p.Hide && p.Name !== "all" && p.Name !== "alias",
);
function paint(list) {
if (list.length === 0) {
grid.innerHTML = `<p class="empty" style="grid-column:1/-1">No backends match.</p>`;
grid.innerHTML = `<p class="empty" style="grid-column:1/-1">${t("configure.no_match")}</p>`;
return;
}
grid.innerHTML = list
@@ -165,10 +164,10 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
app.innerHTML = `
<div class="section-head">
<div>
<h2>${isEdit ? "Edit" : "New"} ${escapeHtml(provider.Name)} remote</h2>
<h2>${isEdit ? t("configure.edit_title", provider.Name) : t("configure.create_title", provider.Name)}</h2>
<p class="subtitle">${escapeHtml(provider.Description || "")}</p>
</div>
<a class="btn btn-secondary btn-sm" href="#/remotes">Cancel</a>
<a class="btn btn-secondary btn-sm" href="#/remotes">${t("configure.cancel")}</a>
</div>
<form id="remote-form" class="card-outline" style="display:flex;flex-direction:column;gap:16px;max-width:760px">
@@ -176,7 +175,7 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
<div class="form-grid">
<div class="field field-full">
<label>Remote name <span class="field-required">*</span></label>
<label>${t("configure.name")} <span class="field-required">*</span></label>
<input
class="input"
name="_remote_name"
@@ -187,20 +186,20 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
>
</div>
${required.length > 0 ? `<div class="form-section-title">Required</div>` : ""}
${required.length > 0 ? `<div class="form-section-title">${t("configure.section.required")}</div>` : ""}
${required.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")}
${basic.length > 0 ? `<div class="form-section-title">Options</div>` : ""}
${basic.length > 0 ? `<div class="form-section-title">${t("configure.section.options")}</div>` : ""}
${basic.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")}
${advanced.length > 0 ? `
<div class="advanced-toggle-wrap">
<button type="button" class="btn btn-secondary btn-sm" id="advanced-toggle">
Show advanced options (${advanced.length})
${t("configure.show_advanced", advanced.length)}
</button>
</div>
<div id="advanced-section" class="advanced-section hidden">
<div class="form-section-title">Advanced</div>
<div class="form-section-title">${t("configure.section.advanced")}</div>
${advanced.map((opt) => fieldHtml(opt, currentValues, isEdit)).join("")}
</div>
` : ""}
@@ -208,16 +207,16 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
<div class="toolbar">
<button type="submit" class="btn btn-primary" ${requiresOAuth ? "disabled" : ""}>
${isEdit ? "Save changes" : "Create remote"}
${isEdit ? t("configure.save") : t("configure.create")}
</button>
<a class="btn btn-secondary" href="#/remotes">Cancel</a>
<a class="btn btn-secondary" href="#/remotes">${t("configure.cancel")}</a>
</div>
${isEdit ? `
<div class="danger-zone">
<h4>Delete this remote</h4>
<p>Permanently remove <code>${escapeHtml(remoteName)}</code> from rclone.conf.</p>
<button type="button" class="btn btn-danger" id="delete-btn">Delete remote</button>
<h4>${t("configure.delete_section_title")}</h4>
<p>${t("configure.delete_section_body", escapeHtml(remoteName))}</p>
<button type="button" class="btn btn-danger" id="delete-btn">${t("configure.delete_btn")}</button>
</div>
` : ""}
</form>
@@ -235,27 +234,25 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
advToggle.addEventListener("click", () => {
const hidden = advSection.classList.toggle("hidden");
advToggle.textContent = hidden
? `Show advanced options (${advanced.length})`
: `Hide advanced options (${advanced.length})`;
? t("configure.show_advanced", advanced.length)
: t("configure.hide_advanced", advanced.length);
});
}
// Delete button
const deleteBtn = document.getElementById("delete-btn");
if (deleteBtn) {
deleteBtn.addEventListener("click", async () => {
if (!confirm(`Delete remote ${remoteName}? This cannot be undone.`)) return;
if (!confirm(t("remotes.delete_confirm", remoteName))) return;
try {
await post("config/delete", { name: remoteName });
toast(`Deleted ${remoteName}`, "success");
toast(t("remotes.deleted", remoteName), "success");
location.hash = "#/remotes";
} catch (e) {
toast(`Delete failed: ${e.message}`, "error");
toast(`${t("remotes.delete_failed")}: ${e.message}`, "error");
}
});
}
// Submit
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (requiresOAuth) return;
@@ -275,10 +272,10 @@ async function renderForm({ provider, mode, remoteName = "", currentValues = {}
const endpoint = isEdit ? "config/update" : "config/create";
await post(endpoint, body);
toast(`${isEdit ? "Updated" : "Created"} ${name}`, "success");
toast(isEdit ? t("configure.updated", name) : t("configure.created", name), "success");
location.hash = "#/remotes";
} catch (err) {
toast(`Save failed: ${err.message}`, "error");
toast(`${t("configure.save_failed")}: ${err.message}`, "error");
}
});
}
@@ -289,11 +286,10 @@ function oauthBanner(providerName) {
return `
<div class="banner banner-warning">
<div>
<strong>${escapeHtml(providerName)}</strong> requires OAuth authorization,
which this web GUI cant complete inside a container. Please configure it
from a terminal first:
<strong>${t("configure.oauth_title")}</strong>
${t("configure.oauth_body", escapeHtml(providerName))}
<code>rclone config</code>
Once the remote exists, you can edit its non-secret options here.
${t("configure.oauth_hint")}
</div>
</div>
`;
@@ -327,7 +323,7 @@ function inputHtmlFor(opt, value, isEdit) {
// already obscured and resubmitting would double-obscure. Show empty
// with a "(unchanged)" placeholder.
if (opt.IsPassword && isEdit) {
return `<input class="input" name="opt_${escapeHtml(opt.Name)}" type="password" autocomplete="off" placeholder="(unchanged)">`;
return `<input class="input" name="opt_${escapeHtml(opt.Name)}" type="password" autocomplete="off" placeholder="${t("configure.password_unchanged")}">`;
}
const name = `opt_${escapeHtml(opt.Name)}`;
@@ -343,12 +339,11 @@ function inputHtmlFor(opt, value, isEdit) {
}
if (opt.IsPassword) {
return `<input class="input" name="${name}" type="password" autocomplete="off" placeholder="secret">`;
return `<input class="input" name="${name}" type="password" autocomplete="off" placeholder="${t("configure.password_secret")}">`;
}
// Examples → select with custom override
if (opt.Examples && opt.Examples.length > 0) {
const opts = ['<option value="">— pick —</option>']
const opts = [`<option value="">${t("configure.example_pick")}</option>`]
.concat(
opt.Examples.map(
(ex) =>
@@ -359,9 +354,9 @@ function inputHtmlFor(opt, value, isEdit) {
return `
<select class="select" name="${name}" data-has-custom="1">
${opts}
<option value="__custom__"${currentValue && !opt.Examples.some((e) => e.Value === currentValue) ? " selected" : ""}>Custom…</option>
<option value="__custom__"${currentValue && !opt.Examples.some((e) => e.Value === currentValue) ? " selected" : ""}>${t("configure.example_custom")}</option>
</select>
<input class="input" name="${name}__custom" type="text" value="${escapeAttr(currentValue)}" style="margin-top:8px;display:none" placeholder="custom value" autocomplete="off">
<input class="input" name="${name}__custom" type="text" value="${escapeAttr(currentValue)}" style="margin-top:8px;display:none" placeholder="${t("configure.example_custom_placeholder")}" autocomplete="off">
`;
}