feat(web): add SPA i18n with browser-language detection (zh/en, default en)
- detect locale from navigator.language (zh* -> zh, otherwise en) at startup;
<html lang> switches accordingly; English is the default
- index.html carries English default text plus data-i18n /
data-i18n-placeholder / data-i18n-aria attributes
- config.js holds inline en/zh dictionaries, t(key, vars) and detectLocale();
dynamic Chinese strings become t() calls with {var} interpolation
- no new asset files (src/web.rs serves only config.js and styles.css);
stays a build-less vanilla-JS single page
- config.test.mjs updated to the English defaults; adds dictionary key-parity
and markup key-coverage tests
This commit is contained in:
parent
f16b747672
commit
6868d47cd0
600
web/config.js
600
web/config.js
|
|
@ -1,6 +1,422 @@
|
||||||
(() => {
|
(() => {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// I18N — browser-language detection, zh/en message dictionaries, t() helper.
|
||||||
|
// Stays vanilla JS; no build step, no extra asset file (src/web.rs only serves
|
||||||
|
// config.js + styles.css). HTML holds English default text + data-i18n attrs;
|
||||||
|
// applyI18n() swaps in Chinese when the browser prefers zh. Default: English.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
const MESSAGES = Object.freeze({
|
||||||
|
en: {
|
||||||
|
"app.title": "Service Configuration · MessageWeave",
|
||||||
|
"brand.aria": "MessageWeave configuration home",
|
||||||
|
"brand.tagline": "Service configuration",
|
||||||
|
"topbar.note": "Secure configuration channel",
|
||||||
|
"intro.eyebrow": "Service management",
|
||||||
|
"intro.title": "Manage your service connections",
|
||||||
|
"intro.copy": "Configure JMAP, Telegram, LLM, access allowlist and runtime parameters. Business configuration is stored in external Redis and hot-reloaded immediately after a successful save.",
|
||||||
|
"notice.bootstrap": "Create a short-lived admin session using Redis management credentials.",
|
||||||
|
"setup.eyebrow": "Setup guide",
|
||||||
|
"setup.title": "Service startup configuration",
|
||||||
|
"setup.checking": "Checking service startup configuration…",
|
||||||
|
"setup.checkingBtn": "Checking…",
|
||||||
|
"setup.privacy": "Startup secrets must be configured in the deployment environment. Secrets are never saved or echoed in the page.",
|
||||||
|
"setup.retry": "Re-check",
|
||||||
|
"setup.modeMissing": "The service is in setup mode. Provide the following startup variables in the deployment environment:",
|
||||||
|
"setup.modeNoMissing": "The service is in setup mode, but the status endpoint did not return the missing startup variable names.",
|
||||||
|
"setup.unknown": "Unable to confirm whether the service has completed startup configuration. The authorization entry stays hidden; re-check the service status.",
|
||||||
|
"setup.fetchError": "Unable to read the service startup status right now. To protect management credentials, the authorization entry stays hidden; confirm the service is available and re-check.",
|
||||||
|
"auth.eyebrow": "Authorization required",
|
||||||
|
"auth.title": "Create admin session",
|
||||||
|
"auth.bodyPre": "Enter the password of the Redis ACL user from the deployment ",
|
||||||
|
"auth.bodyPost": ". The server issues a 15-minute admin session; the password and session exist only in the current page memory.",
|
||||||
|
"auth.label": "Redis management credential",
|
||||||
|
"auth.placeholder": "Enter the ACL password from the Redis URL",
|
||||||
|
"auth.submit": "Create session",
|
||||||
|
"auth.submitBusy": "Authorizing…",
|
||||||
|
"auth.hint": "Never written to localStorage, sessionStorage or cookies. You must re-authorize after closing or refreshing the page.",
|
||||||
|
"config.aria": "Business configuration and runtime parameters",
|
||||||
|
"session.active": "Admin session active",
|
||||||
|
"session.expiryDefault": "Session kept only in current page memory",
|
||||||
|
"session.expiry": "Admin session expires in about {n} minutes; kept only in page memory",
|
||||||
|
"session.expired": "The admin session has expired. The session and form contents have been cleared from the page. Please re-authorize.",
|
||||||
|
"session.revoked": "Signed out; the admin session and secrets in the form have been cleared from page memory.",
|
||||||
|
"session.invalid": "The admin session is no longer valid. The page contents have been cleared. Please recreate the session.",
|
||||||
|
"session.invalidBusiness": "The admin session is no longer valid. The session and configuration have been cleared from the page. Please re-authorize.",
|
||||||
|
"service.enable": "Enable business processing",
|
||||||
|
"service.loading": "Loading…",
|
||||||
|
"service.enabled": "Business entry enabled",
|
||||||
|
"service.disabled": "Business entry disabled (fail-closed)",
|
||||||
|
"service.enabledToast": "Business processing enabled.",
|
||||||
|
"service.disabledToast": "Business processing disabled; the entry will return a not-enabled state.",
|
||||||
|
"service.saveFail": "Unable to save the business toggle state.",
|
||||||
|
"reload.button": "Reload runtime parameters",
|
||||||
|
"reload.busy": "Loading…",
|
||||||
|
"logout.button": "Sign out and clear session",
|
||||||
|
"business.eyebrow": "Full replacement",
|
||||||
|
"business.title": "Business connections and auth",
|
||||||
|
"business.persist": "Encrypted in Redis",
|
||||||
|
"callout.title": "Business configuration is not read from the server.",
|
||||||
|
"callout.bodyPre": " To avoid secret echo, the API does not expose a business configuration GET. Every submission must re-fill the complete configuration, including all required secrets; optional account ID and LLM fields are sent as ",
|
||||||
|
"callout.bodyPost": " when left blank.",
|
||||||
|
"legend.business": "Full business configuration",
|
||||||
|
"jmap.title": "JMAP mailbox",
|
||||||
|
"jmap.desc": "The server must use HTTPS; the URL must not contain credentials, query parameters or fragments.",
|
||||||
|
"jmap.urlHint": "HTTPS address without username, password, query parameters or #fragment.",
|
||||||
|
"jmap.userLabel": "Email username",
|
||||||
|
"jmap.passLabel": "App-specific password",
|
||||||
|
"jmap.accountPh": "Leave blank to use the session primary account",
|
||||||
|
"secret.reenter": "Re-enter on each full save",
|
||||||
|
"telegram.title": "Telegram and access auth",
|
||||||
|
"telegram.desc": "Only chat IDs in the allowlist may trigger bot operations.",
|
||||||
|
"telegram.chatLabel": "Target Chat ID",
|
||||||
|
"telegram.chatPh": "e.g. -1001234567890",
|
||||||
|
"telegram.chatHint": "Accepts a full signed 64-bit integer.",
|
||||||
|
"telegram.allowPh": "One chat ID per line or comma-separated",
|
||||||
|
"telegram.allowHint": "Integer list; format and duplicates are checked before saving.",
|
||||||
|
"telegram.workerPh": "Used by /worker and compatible admin APIs; re-enter on each full save",
|
||||||
|
"telegram.workerHint": "This value differs from the current admin session. The page never reads or echoes an existing token.",
|
||||||
|
"llm.title": "LLM service",
|
||||||
|
"llm.desc": "When enabled, an HTTPS Base URL, model name and API key are required.",
|
||||||
|
"llm.enableLabel": "Enable LLM",
|
||||||
|
"llm.enableHint": "Allow the bot to call OpenAI-compatible model services.",
|
||||||
|
"llm.netLabel": "Allow network calls",
|
||||||
|
"llm.netHint": "When off, no LLM client is created.",
|
||||||
|
"llm.urlHint": "HTTPS is required when LLM is enabled.",
|
||||||
|
"llm.modelLabel": "Model name",
|
||||||
|
"llm.modelPh": "Leave blank for null",
|
||||||
|
"llm.keyPh": "Required when LLM is enabled; never echoed",
|
||||||
|
"business.saveStateInitial": "You can fill in the full configuration after the admin session is verified.",
|
||||||
|
"business.saveStateReady": "Fill in the complete BusinessConfigWire to submit. Secrets are never echoed.",
|
||||||
|
"business.save": "Save business config and hot-reload",
|
||||||
|
"business.saveBusy": "Saving and hot-reloading…",
|
||||||
|
"business.saved": "Business configuration saved and new configuration hot-reloaded.",
|
||||||
|
"business.savedToast": "Business configuration saved and hot-reloaded. Secret inputs cleared; re-fill on the next full replacement.",
|
||||||
|
"business.dirty": "The form has unsubmitted content; each submission fully replaces the existing business configuration.",
|
||||||
|
"business.unsaved": "No save-success confirmation received; check the service status.",
|
||||||
|
"business.submittingToast": "Submitting the full business configuration; the backend builds clients first, then saves and swaps the running instance…",
|
||||||
|
"runtime.eyebrow": "Outbound requests",
|
||||||
|
"runtime.title": "Timeouts and retries",
|
||||||
|
"runtime.persist": "Stored in Redis",
|
||||||
|
"runtime.bodyPre": "Runtime parameters are read by ",
|
||||||
|
"runtime.bodyMid": " and updated by ",
|
||||||
|
"runtime.bodyPost": "; new values apply immediately to subsequent requests after saving.",
|
||||||
|
"legend.runtime": "Runtime parameters",
|
||||||
|
"runtime.jmapLabel": "JMAP timeout",
|
||||||
|
"runtime.jmapDesc": "Upper wait limit for a single JMAP request.",
|
||||||
|
"runtime.jmapHint": "100–300,000; default 15,000.",
|
||||||
|
"runtime.tgLabel": "Telegram timeout",
|
||||||
|
"runtime.tgDesc": "Upper wait limit for a single Telegram request.",
|
||||||
|
"runtime.tgHint": "100–300,000; default 10,000.",
|
||||||
|
"runtime.llmLabel": "LLM timeout",
|
||||||
|
"runtime.llmDesc": "Upper wait limit for a single model request.",
|
||||||
|
"runtime.llmHint": "100–300,000; default 30,000.",
|
||||||
|
"runtime.retriesLabel": "Max retries",
|
||||||
|
"runtime.retriesDesc": "Extra attempts after a failure; 0 means no retry.",
|
||||||
|
"runtime.retriesHint": "0–5; default 3.",
|
||||||
|
"runtime.saveStateInitial": "Runtime parameters become editable after a successful read.",
|
||||||
|
"runtime.previewState": "Default-value preview; save is disabled until a read succeeds.",
|
||||||
|
"runtime.loaded": "Runtime parameters read from Redis.",
|
||||||
|
"runtime.readFailState": "Read failed; save is disabled.",
|
||||||
|
"runtime.dirty": "There are unsaved runtime parameter changes.",
|
||||||
|
"runtime.defaultsState": "Defaults restored as preview; they are written to Redis only after saving.",
|
||||||
|
"runtime.saveUnconfirmed": "Save state unconfirmed; save is disabled until a successful re-read.",
|
||||||
|
"runtime.defaults": "Restore defaults",
|
||||||
|
"runtime.save": "Save runtime parameters",
|
||||||
|
"runtime.saveBusy": "Saving…",
|
||||||
|
"runtime.savingToast": "Saving runtime parameters to Redis…",
|
||||||
|
"runtime.readingToast": "Reading runtime parameters from Redis…",
|
||||||
|
"runtime.readToast": "Runtime parameters read. Business configuration still needs to be filled in manually.",
|
||||||
|
"runtime.savedToast": "Runtime parameters saved to Redis and applied immediately to subsequent requests.",
|
||||||
|
"runtime.defaultsToast": "Runtime parameters restored to default-value preview. They are written to Redis only after clicking \"Save runtime parameters\".",
|
||||||
|
"privacy.title": "Secrets are never echoed or persisted to the browser.",
|
||||||
|
"privacy.body": "Business configuration is submitted only to the same-origin API and encrypted into Redis by the backend; this page never reads mailbox contents and uses no local files or database.",
|
||||||
|
"footer.note": "Configuration managed by your server",
|
||||||
|
"common.required": "Required",
|
||||||
|
"common.optional": "Optional",
|
||||||
|
"common.atLeastOne": "At least one",
|
||||||
|
"common.ms": "ms",
|
||||||
|
"common.times": "times",
|
||||||
|
"err.unrecognizedFormat": "The configuration service returned an unrecognized data format.",
|
||||||
|
"err.connectFail": "Unable to connect to the configuration service. Confirm the service is available and retry.",
|
||||||
|
"err.sessionInvalid": "The admin session is no longer valid. Please re-authorize.",
|
||||||
|
"err.runtime400": "Invalid runtime parameter request format (HTTP 400).",
|
||||||
|
"err.runtime422": "Runtime parameters out of allowed range (timeouts 100–300,000 ms, retries 0–5).",
|
||||||
|
"err.runtime503save": "Redis unavailable or initialization incomplete; save state cannot be confirmed. Runtime parameter saving is disabled; restore the connection and re-read.",
|
||||||
|
"err.runtime503load": "Redis unavailable or initialization incomplete. Only the default-value preview is shown and runtime parameter saving is disabled; retry shortly.",
|
||||||
|
"err.runtimeFailStatus": "Runtime parameter request failed (HTTP {status}). Retry shortly.",
|
||||||
|
"err.runtimeFail": "Runtime parameter request failed; check the service status and retry.",
|
||||||
|
"err.unrecognizedRuntime": "The configuration service returned unrecognized runtime parameters.",
|
||||||
|
"err.runtimeOutOfRange": "The configuration service returned out-of-range runtime parameters; check the backend configuration.",
|
||||||
|
"err.invalidInt": "Please enter an integer.",
|
||||||
|
"err.rangeInt": "Please enter an integer between {min} and {max}.",
|
||||||
|
"val.i64": "{label} must be a decimal integer.",
|
||||||
|
"val.i64Range": "{label} exceeds the signed 64-bit integer range.",
|
||||||
|
"val.allowlistEmpty": "Enter at least one chat ID. Whitespace and commas both work as separators.",
|
||||||
|
"val.allowlistNotInt": "\"{entry}\" in the allowlist is not an integer chat ID.",
|
||||||
|
"val.allowlistRange": "\"{entry}\" in the allowlist exceeds the signed 64-bit integer range.",
|
||||||
|
"val.allowlistDup": "Duplicate chat ID in the allowlist: {entry}.",
|
||||||
|
"val.httpsUrl": "{label} must be a valid HTTPS URL.",
|
||||||
|
"val.https": "{label} must use HTTPS.",
|
||||||
|
"val.noCreds": "{label} must not contain a username or password.",
|
||||||
|
"val.noQuery": "{label} must not contain query parameters or a #fragment.",
|
||||||
|
"val.empty": "{label} cannot be empty.",
|
||||||
|
"val.llmUrl": "Base URL is required when LLM is enabled.",
|
||||||
|
"val.llmModel": "Model name is required when LLM is enabled.",
|
||||||
|
"val.llmKey": "API key is required when LLM is enabled.",
|
||||||
|
"berr.401": "The admin session is no longer valid. The session and form contents have been cleared from the page; please re-authorize.",
|
||||||
|
"berr.400": "The backend could not parse the submitted JSON (HTTP 400); check the fields and retry.",
|
||||||
|
"berr.422": "Business configuration validation failed (HTTP 422). Check required fields, HTTPS URLs, LLM settings and the chat ID allowlist. The server does not return secrets or error details.",
|
||||||
|
"berr.503": "Business configuration was not confirmed saved (HTTP 503). Redis may not be ready, or the backend could not build the JMAP/LLM client; the page keeps this input for review and retry.",
|
||||||
|
"berr.status": "Business configuration request failed (HTTP {status}). Retry shortly.",
|
||||||
|
"berr.fail": "{message} The request result may be unconfirmed; after checking, re-submit the complete configuration.",
|
||||||
|
"berr.failDefault": "Business configuration request failed.",
|
||||||
|
"login.enterCred": "Enter the Redis ACL management credential. The input is never written to browser storage.",
|
||||||
|
"login.creatingToast": "Creating a short-lived admin session…",
|
||||||
|
"login.invalidResponse": "The admin endpoint returned an invalid session response.",
|
||||||
|
"login.successToast": "Admin session created. Runtime parameters are being read; business configuration must be filled in again in full.",
|
||||||
|
"login.401": "The Redis management credential is invalid, or admin sessions are not enabled. Check the Redis URL ACL password.",
|
||||||
|
"login.503": "Redis unavailable or initialization incomplete; unable to create an admin session right now. Retry shortly.",
|
||||||
|
"login.status": "Unable to create an admin session (HTTP {status}).",
|
||||||
|
"login.fail": "Unable to connect to the admin service; check the network and retry.",
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
"app.title": "业务配置 · MessageWeave",
|
||||||
|
"brand.aria": "MessageWeave 配置首页",
|
||||||
|
"brand.tagline": "业务配置",
|
||||||
|
"topbar.note": "安全配置通道",
|
||||||
|
"intro.eyebrow": "服务管理",
|
||||||
|
"intro.title": "管理你的服务连接",
|
||||||
|
"intro.copy": "配置 JMAP、Telegram、LLM、访问白名单和运行参数。业务配置保存在外部 Redis,成功保存后会立即热加载。",
|
||||||
|
"notice.bootstrap": "使用 Redis 管理凭据创建短期管理会话。",
|
||||||
|
"setup.eyebrow": "配置引导",
|
||||||
|
"setup.title": "服务启动配置",
|
||||||
|
"setup.checking": "正在检查服务启动配置…",
|
||||||
|
"setup.checkingBtn": "正在检查…",
|
||||||
|
"setup.privacy": "启动所需密钥请在部署环境中配置。密钥不会在页面保存或回显。",
|
||||||
|
"setup.retry": "重新检查",
|
||||||
|
"setup.modeMissing": "服务处于配置引导模式。请在部署环境补齐以下启动变量:",
|
||||||
|
"setup.modeNoMissing": "服务处于配置引导模式,但状态接口未返回缺少的启动变量名称。",
|
||||||
|
"setup.unknown": "无法确认服务是否已完成启动配置。管理授权入口保持隐藏,请重新检查服务状态。",
|
||||||
|
"setup.fetchError": "暂时无法读取服务启动状态。为保护管理凭据,授权入口保持隐藏;请确认服务可用后重新检查。",
|
||||||
|
"auth.eyebrow": "需要授权",
|
||||||
|
"auth.title": "创建管理会话",
|
||||||
|
"auth.bodyPre": "输入部署时 ",
|
||||||
|
"auth.bodyPost": " 中 Redis ACL 用户的密码。服务端会签发有效期 15 分钟的管理会话;密码和会话只在当前页面内存中使用。",
|
||||||
|
"auth.label": "Redis 管理凭据",
|
||||||
|
"auth.placeholder": "输入 Redis URL 中的 ACL 密码",
|
||||||
|
"auth.submit": "创建会话",
|
||||||
|
"auth.submitBusy": "正在授权…",
|
||||||
|
"auth.hint": "不会写入 localStorage、sessionStorage 或 Cookie。关闭或刷新页面后需要重新授权。",
|
||||||
|
"config.aria": "业务配置与运行参数",
|
||||||
|
"session.active": "管理会话有效",
|
||||||
|
"session.expiryDefault": "会话仅保存在当前页面内存",
|
||||||
|
"session.expiry": "会话约 {n} 分钟后过期;只保存在页面内存",
|
||||||
|
"session.expired": "管理会话已过期,已清除页面中的会话和表单内容。请重新授权。",
|
||||||
|
"session.revoked": "已退出;管理会话和表单中的密钥已从页面内存清除。",
|
||||||
|
"session.invalid": "管理会话已失效,页面内容已清除。请重新创建会话。",
|
||||||
|
"session.invalidBusiness": "管理会话已失效,已清除页面中的会话与配置。请重新授权。",
|
||||||
|
"service.enable": "启用业务处理",
|
||||||
|
"service.loading": "读取中…",
|
||||||
|
"service.enabled": "业务入口已启用",
|
||||||
|
"service.disabled": "业务入口已关闭(fail-closed)",
|
||||||
|
"service.enabledToast": "业务处理已启用。",
|
||||||
|
"service.disabledToast": "业务处理已关闭,入口将返回未启用状态。",
|
||||||
|
"service.saveFail": "无法保存业务开关状态。",
|
||||||
|
"reload.button": "重新读取运行参数",
|
||||||
|
"reload.busy": "正在读取…",
|
||||||
|
"logout.button": "退出并清除会话",
|
||||||
|
"business.eyebrow": "完整替换",
|
||||||
|
"business.title": "业务连接与鉴权",
|
||||||
|
"business.persist": "Redis 加密保存",
|
||||||
|
"callout.title": "业务配置不会从服务器读取。",
|
||||||
|
"callout.bodyPre": " 为避免密钥回显,API 不提供业务配置 GET。每次提交都必须重新填写完整配置,包括所有必填密钥;可选的 account ID 与 LLM 字段留空时会发送为 ",
|
||||||
|
"callout.bodyPost": "。",
|
||||||
|
"legend.business": "完整业务配置",
|
||||||
|
"jmap.title": "JMAP 邮箱",
|
||||||
|
"jmap.desc": "服务端必须使用 HTTPS,URL 中不能包含凭据、查询参数或片段。",
|
||||||
|
"jmap.urlHint": "HTTPS 地址,不含用户名、密码、查询参数或 #片段。",
|
||||||
|
"jmap.userLabel": "邮箱用户名",
|
||||||
|
"jmap.passLabel": "应用专用密码",
|
||||||
|
"jmap.accountPh": "留空使用 session 主账户",
|
||||||
|
"secret.reenter": "每次完整保存时重新输入",
|
||||||
|
"telegram.title": "Telegram 与访问鉴权",
|
||||||
|
"telegram.desc": "仅允许白名单中的 chat ID 触发机器人操作。",
|
||||||
|
"telegram.chatLabel": "目标 Chat ID",
|
||||||
|
"telegram.chatPh": "例如 -1001234567890",
|
||||||
|
"telegram.chatHint": "接受完整有符号 64 位整数。",
|
||||||
|
"telegram.allowPh": "每行或逗号分隔一个 chat ID",
|
||||||
|
"telegram.allowHint": "整数列表;保存前会检查格式与重复项。",
|
||||||
|
"telegram.workerPh": "/worker 与兼容管理 API 使用;每次完整保存时重新输入",
|
||||||
|
"telegram.workerHint": "此值与当前管理会话不同。页面不会读取或回显已有 token。",
|
||||||
|
"llm.title": "LLM 服务",
|
||||||
|
"llm.desc": "启用后要求 HTTPS Base URL、模型名和 API key。",
|
||||||
|
"llm.enableLabel": "启用 LLM",
|
||||||
|
"llm.enableHint": "允许机器人调用兼容 OpenAI 的模型服务。",
|
||||||
|
"llm.netLabel": "允许网络调用",
|
||||||
|
"llm.netHint": "关闭时不会创建 LLM 客户端。",
|
||||||
|
"llm.urlHint": "启用 LLM 时必须使用 HTTPS。",
|
||||||
|
"llm.modelLabel": "模型名称",
|
||||||
|
"llm.modelPh": "留空表示 null",
|
||||||
|
"llm.keyPh": "启用 LLM 时必填;不会回显",
|
||||||
|
"business.saveStateInitial": "完成管理会话验证后可填写完整配置。",
|
||||||
|
"business.saveStateReady": "完整填写 BusinessConfigWire 后可提交。密钥不会回显。",
|
||||||
|
"business.save": "保存业务配置并热加载",
|
||||||
|
"business.saveBusy": "正在保存并热加载…",
|
||||||
|
"business.saved": "业务配置已保存,新配置已热加载。",
|
||||||
|
"business.savedToast": "业务配置已保存并热加载。密钥输入已清空;下次完整替换时需要重新填写。",
|
||||||
|
"business.dirty": "表单包含未提交内容;每次提交会完整替换现有业务配置。",
|
||||||
|
"business.unsaved": "未收到保存成功确认;请检查服务状态。",
|
||||||
|
"business.submittingToast": "正在提交完整业务配置;后端会先构建客户端,再保存并切换运行实例…",
|
||||||
|
"runtime.eyebrow": "出站请求",
|
||||||
|
"runtime.title": "超时与重试",
|
||||||
|
"runtime.persist": "Redis 保存",
|
||||||
|
"runtime.bodyPre": "运行参数由 ",
|
||||||
|
"runtime.bodyMid": " 读取、由 ",
|
||||||
|
"runtime.bodyPost": " 更新;新值保存后立即用于后续请求。",
|
||||||
|
"legend.runtime": "运行参数",
|
||||||
|
"runtime.jmapLabel": "JMAP 超时",
|
||||||
|
"runtime.jmapDesc": "单次 JMAP 请求的等待上限。",
|
||||||
|
"runtime.jmapHint": "100–300,000;默认 15,000。",
|
||||||
|
"runtime.tgLabel": "Telegram 超时",
|
||||||
|
"runtime.tgDesc": "单次 Telegram 请求的等待上限。",
|
||||||
|
"runtime.tgHint": "100–300,000;默认 10,000。",
|
||||||
|
"runtime.llmLabel": "LLM 超时",
|
||||||
|
"runtime.llmDesc": "单次模型请求的等待上限。",
|
||||||
|
"runtime.llmHint": "100–300,000;默认 30,000。",
|
||||||
|
"runtime.retriesLabel": "最大重试次数",
|
||||||
|
"runtime.retriesDesc": "失败后额外尝试的次数,0 表示不重试。",
|
||||||
|
"runtime.retriesHint": "0–5;默认 3。",
|
||||||
|
"runtime.saveStateInitial": "读取成功后可编辑运行参数。",
|
||||||
|
"runtime.previewState": "默认值预览;读取后才能保存。",
|
||||||
|
"runtime.loaded": "已读取 Redis 中的运行参数。",
|
||||||
|
"runtime.readFailState": "读取未成功,保存已禁用。",
|
||||||
|
"runtime.dirty": "有尚未保存的运行参数修改。",
|
||||||
|
"runtime.defaultsState": "已恢复默认预览值;保存后才会写入 Redis。",
|
||||||
|
"runtime.saveUnconfirmed": "保存状态无法确认;重新读取成功前已禁用保存。",
|
||||||
|
"runtime.defaults": "恢复默认值",
|
||||||
|
"runtime.save": "保存运行参数",
|
||||||
|
"runtime.saveBusy": "正在保存…",
|
||||||
|
"runtime.savingToast": "正在保存运行参数到 Redis…",
|
||||||
|
"runtime.readingToast": "正在从 Redis 读取运行参数…",
|
||||||
|
"runtime.readToast": "运行参数已读取。业务配置仍需手动完整填写。",
|
||||||
|
"runtime.savedToast": "运行参数已保存到 Redis,并立即应用于后续请求。",
|
||||||
|
"runtime.defaultsToast": "运行参数已恢复为默认值预览。点击“保存运行参数”后才会更新 Redis。",
|
||||||
|
"privacy.title": "密钥不会回显或持久化到浏览器。",
|
||||||
|
"privacy.body": "业务配置只提交至同源 API,并由后端加密写入 Redis;本页面不读取邮箱内容、不使用本地文件或数据库。",
|
||||||
|
"footer.note": "配置由你的服务端管理",
|
||||||
|
"common.required": "必填",
|
||||||
|
"common.optional": "可选",
|
||||||
|
"common.atLeastOne": "至少一项",
|
||||||
|
"common.ms": "毫秒",
|
||||||
|
"common.times": "次",
|
||||||
|
"err.unrecognizedFormat": "配置服务返回了无法识别的数据格式。",
|
||||||
|
"err.connectFail": "无法连接配置服务。请确认服务可用后重试。",
|
||||||
|
"err.sessionInvalid": "管理会话已失效,请重新授权。",
|
||||||
|
"err.runtime400": "运行参数请求格式无效(HTTP 400)。",
|
||||||
|
"err.runtime422": "运行参数超出允许范围(超时 100–300,000 毫秒,重试 0–5 次)。",
|
||||||
|
"err.runtime503save": "Redis 不可用或初始化尚未完成;保存状态无法确认。已禁用运行参数保存,请恢复连接后重新读取。",
|
||||||
|
"err.runtime503load": "Redis 不可用或初始化尚未完成。当前只显示默认值预览,运行参数保存已禁用;请稍后重试。",
|
||||||
|
"err.runtimeFailStatus": "运行参数请求失败(HTTP {status})。请稍后重试。",
|
||||||
|
"err.runtimeFail": "运行参数请求失败,请检查服务状态后重试。",
|
||||||
|
"err.unrecognizedRuntime": "配置服务返回了无法识别的运行参数。",
|
||||||
|
"err.runtimeOutOfRange": "配置服务返回了超出范围的运行参数,请检查后端配置。",
|
||||||
|
"err.invalidInt": "请输入整数。",
|
||||||
|
"err.rangeInt": "请输入 {min} 到 {max} 之间的整数。",
|
||||||
|
"val.i64": "{label} 必须是十进制整数。",
|
||||||
|
"val.i64Range": "{label} 超出有符号 64 位整数范围。",
|
||||||
|
"val.allowlistEmpty": "至少填写一个 chat ID。空白和逗号均可用作分隔符。",
|
||||||
|
"val.allowlistNotInt": "白名单中的“{entry}”不是整数 chat ID。",
|
||||||
|
"val.allowlistRange": "白名单中的“{entry}”超出有符号 64 位整数范围。",
|
||||||
|
"val.allowlistDup": "白名单中有重复的 chat ID:{entry}。",
|
||||||
|
"val.httpsUrl": "{label} 必须是有效的 HTTPS URL。",
|
||||||
|
"val.https": "{label} 必须使用 HTTPS。",
|
||||||
|
"val.noCreds": "{label} 不能包含用户名或密码。",
|
||||||
|
"val.noQuery": "{label} 不能包含查询参数或 #片段。",
|
||||||
|
"val.empty": "{label} 不能为空。",
|
||||||
|
"val.llmUrl": "启用 LLM 时必须填写 Base URL。",
|
||||||
|
"val.llmModel": "启用 LLM 时必须填写模型名称。",
|
||||||
|
"val.llmKey": "启用 LLM 时必须填写 API key。",
|
||||||
|
"berr.401": "管理会话已失效。页面已清除会话和表单内容,请重新授权。",
|
||||||
|
"berr.400": "后端无法解析提交的 JSON(HTTP 400);请检查字段后重试。",
|
||||||
|
"berr.422": "业务配置校验失败(HTTP 422)。请检查必填项、HTTPS URL、LLM 设置和 chat ID 白名单。服务端没有返回密钥或错误详情。",
|
||||||
|
"berr.503": "业务配置未获成功确认(HTTP 503)。Redis 可能尚未就绪,或后端无法建立 JMAP/LLM 客户端;页面保留本次输入供检查和重试。",
|
||||||
|
"berr.status": "业务配置请求失败(HTTP {status})。请稍后重试。",
|
||||||
|
"berr.fail": "{message} 请求结果可能无法确认;检查后可重新提交完整配置。",
|
||||||
|
"berr.failDefault": "业务配置请求失败。",
|
||||||
|
"login.enterCred": "请输入 Redis ACL 管理凭据。输入不会写入浏览器存储。",
|
||||||
|
"login.creatingToast": "正在创建短期管理会话…",
|
||||||
|
"login.invalidResponse": "管理接口返回了无效的会话响应。",
|
||||||
|
"login.successToast": "管理会话已创建。运行参数正在读取;业务配置需要完整重新填写。",
|
||||||
|
"login.401": "Redis 管理凭据无效,或服务尚未启用管理会话。请检查 Redis URL ACL 密码。",
|
||||||
|
"login.503": "Redis 不可用或初始化尚未完成,暂时无法创建管理会话。请稍后重试。",
|
||||||
|
"login.status": "无法创建管理会话(HTTP {status})。",
|
||||||
|
"login.fail": "无法连接管理服务,请检查网络后重试。",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function detectLocale() {
|
||||||
|
let pref = "";
|
||||||
|
try {
|
||||||
|
if (typeof navigator !== "undefined" && navigator) {
|
||||||
|
pref = (Array.isArray(navigator.languages) && navigator.languages[0]) || navigator.language || "";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
pref = "";
|
||||||
|
}
|
||||||
|
if (typeof pref === "string" && pref.toLowerCase().startsWith("zh")) return "zh";
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
let locale = detectLocale();
|
||||||
|
|
||||||
|
function t(key, vars) {
|
||||||
|
const dict = MESSAGES[locale] || MESSAGES.en;
|
||||||
|
let str = dict[key];
|
||||||
|
if (str === undefined) str = MESSAGES.en[key];
|
||||||
|
if (str === undefined) return key;
|
||||||
|
if (vars) {
|
||||||
|
for (const [name, value] of Object.entries(vars)) {
|
||||||
|
str = str.split(`{${name}}`).join(String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinList(items) {
|
||||||
|
const sep = locale === "zh" ? "、" : ", ";
|
||||||
|
return items.join(sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyI18n() {
|
||||||
|
const doc = (typeof document !== "undefined") ? document : null;
|
||||||
|
if (!doc) return;
|
||||||
|
const root = doc.documentElement;
|
||||||
|
if (root && root.setAttribute) root.setAttribute("lang", locale === "zh" ? "zh-CN" : "en");
|
||||||
|
if (typeof doc.querySelectorAll !== "function") return;
|
||||||
|
for (const el of doc.querySelectorAll("[data-i18n]")) {
|
||||||
|
const key = el.getAttribute("data-i18n");
|
||||||
|
if (key) el.textContent = t(key);
|
||||||
|
}
|
||||||
|
for (const el of doc.querySelectorAll("[data-i18n-placeholder]")) {
|
||||||
|
const key = el.getAttribute("data-i18n-placeholder");
|
||||||
|
if (key) el.setAttribute("placeholder", t(key));
|
||||||
|
}
|
||||||
|
for (const el of doc.querySelectorAll("[data-i18n-aria]")) {
|
||||||
|
const key = el.getAttribute("data-i18n-aria");
|
||||||
|
if (key) el.setAttribute("aria-label", t(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose a tiny debug surface for runtime inspection and tests.
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.__mw = { t, detectLocale, getLocale: () => locale, messages: MESSAGES, joinList };
|
||||||
|
}
|
||||||
|
|
||||||
|
applyI18n();
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const RUNTIME_DEFAULTS = Object.freeze({
|
const RUNTIME_DEFAULTS = Object.freeze({
|
||||||
jmap_timeout_ms: 15000,
|
jmap_timeout_ms: 15000,
|
||||||
telegram_timeout_ms: 10000,
|
telegram_timeout_ms: 10000,
|
||||||
|
|
@ -84,10 +500,10 @@
|
||||||
defaultsButton.disabled = busy || !runtimeLoaded || !adminSession;
|
defaultsButton.disabled = busy || !runtimeLoaded || !adminSession;
|
||||||
businessSaveButton.classList.toggle("is-busy", busy && busyAction === "business-save");
|
businessSaveButton.classList.toggle("is-busy", busy && busyAction === "business-save");
|
||||||
runtimeSaveButton.classList.toggle("is-busy", busy && busyAction === "runtime-save");
|
runtimeSaveButton.classList.toggle("is-busy", busy && busyAction === "runtime-save");
|
||||||
connectButton.textContent = busy && busyAction === "login" ? "正在授权…" : "创建会话";
|
connectButton.textContent = busy && busyAction === "login" ? t("auth.submitBusy") : t("auth.submit");
|
||||||
reloadButton.textContent = busy && busyAction === "runtime-load" ? "正在读取…" : "重新读取运行参数";
|
reloadButton.textContent = busy && busyAction === "runtime-load" ? t("reload.busy") : t("reload.button");
|
||||||
businessSaveButton.querySelector(".button-label").textContent = busy && busyAction === "business-save" ? "正在保存并热加载…" : "保存业务配置并热加载";
|
businessSaveButton.querySelector(".button-label").textContent = busy && busyAction === "business-save" ? t("business.saveBusy") : t("business.save");
|
||||||
runtimeSaveButton.querySelector(".button-label").textContent = busy && busyAction === "runtime-save" ? "正在保存…" : "保存运行参数";
|
runtimeSaveButton.querySelector(".button-label").textContent = busy && busyAction === "runtime-save" ? t("runtime.saveBusy") : t("runtime.save");
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBusy(action = "") {
|
function setBusy(action = "") {
|
||||||
|
|
@ -100,7 +516,7 @@
|
||||||
try {
|
try {
|
||||||
return JSON.parse(text);
|
return JSON.parse(text);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("配置服务返回了无法识别的数据格式。");
|
throw new Error(t("err.unrecognizedFormat"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,7 +540,7 @@
|
||||||
try {
|
try {
|
||||||
response = await fetch(path, options);
|
response = await fetch(path, options);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("无法连接配置服务。请确认服务可用后重试。");
|
throw new Error(t("err.connectFail"));
|
||||||
}
|
}
|
||||||
const responseText = await response.text().catch(() => "");
|
const responseText = await response.text().catch(() => "");
|
||||||
if (!response.ok) throw new ApiError(response.status, responseText);
|
if (!response.ok) throw new ApiError(response.status, responseText);
|
||||||
|
|
@ -132,9 +548,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showSetupStatus() {
|
async function showSetupStatus() {
|
||||||
showSetupView("正在检查服务启动配置…");
|
showSetupView(t("setup.checking"));
|
||||||
statusRetryButton.disabled = true;
|
statusRetryButton.disabled = true;
|
||||||
statusRetryButton.textContent = "正在检查…";
|
statusRetryButton.textContent = t("setup.checkingBtn");
|
||||||
try {
|
try {
|
||||||
const status = await request("/api/status", "GET", undefined, "");
|
const status = await request("/api/status", "GET", undefined, "");
|
||||||
if (status && status.ready === true) {
|
if (status && status.ready === true) {
|
||||||
|
|
@ -145,7 +561,7 @@
|
||||||
authCard.hidden = false;
|
authCard.hidden = false;
|
||||||
configPanel.hidden = true;
|
configPanel.hidden = true;
|
||||||
privacyNote.hidden = false;
|
privacyNote.hidden = false;
|
||||||
showNotice("info", "使用 Redis 管理凭据创建短期管理会话。");
|
showNotice("info", t("notice.bootstrap"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const isSetup = status && status.ready === false && status.mode === "configuration-setup";
|
const isSetup = status && status.ready === false && status.mode === "configuration-setup";
|
||||||
|
|
@ -153,31 +569,29 @@
|
||||||
const knownMissing = Array.isArray(status.missing)
|
const knownMissing = Array.isArray(status.missing)
|
||||||
? status.missing.filter((name) => name === "REDIS_URL" || name === "CONFIG_ENCRYPTION_KEY")
|
? status.missing.filter((name) => name === "REDIS_URL" || name === "CONFIG_ENCRYPTION_KEY")
|
||||||
: [];
|
: [];
|
||||||
const message = knownMissing.length
|
const message = knownMissing.length ? t("setup.modeMissing") : t("setup.modeNoMissing");
|
||||||
? "服务处于配置引导模式。请在部署环境补齐以下启动变量:"
|
|
||||||
: "服务处于配置引导模式,但状态接口未返回缺少的启动变量名称。";
|
|
||||||
showSetupView(message, knownMissing);
|
showSetupView(message, knownMissing);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showSetupView("无法确认服务是否已完成启动配置。管理授权入口保持隐藏,请重新检查服务状态。");
|
showSetupView(t("setup.unknown"));
|
||||||
} catch {
|
} catch {
|
||||||
showSetupView("暂时无法读取服务启动状态。为保护管理凭据,授权入口保持隐藏;请确认服务可用后重新检查。");
|
showSetupView(t("setup.fetchError"));
|
||||||
} finally {
|
} finally {
|
||||||
statusRetryButton.disabled = false;
|
statusRetryButton.disabled = false;
|
||||||
statusRetryButton.textContent = "重新检查";
|
statusRetryButton.textContent = t("setup.retry");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadEnabled() {
|
async function loadEnabled() {
|
||||||
const state = await request("/api/enabled");
|
const state = await request("/api/enabled");
|
||||||
serviceEnabled.checked = state.enabled === true;
|
serviceEnabled.checked = state.enabled === true;
|
||||||
serviceEnabledState.textContent = serviceEnabled.checked ? "业务入口已启用" : "业务入口已关闭(fail-closed)";
|
serviceEnabledState.textContent = serviceEnabled.checked ? t("service.enabled") : t("service.disabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSetupView(message, missing = []) {
|
function showSetupView(message, missing = []) {
|
||||||
setupMode = true;
|
setupMode = true;
|
||||||
setupMessage.textContent = message;
|
setupMessage.textContent = message;
|
||||||
setupMissing.textContent = missing.join("、");
|
setupMissing.textContent = joinList(missing);
|
||||||
setupMissing.hidden = missing.length === 0;
|
setupMissing.hidden = missing.length === 0;
|
||||||
setupCard.hidden = false;
|
setupCard.hidden = false;
|
||||||
intro.hidden = true;
|
intro.hidden = true;
|
||||||
|
|
@ -215,8 +629,8 @@
|
||||||
runtimeLoaded = false;
|
runtimeLoaded = false;
|
||||||
runtimeFields.disabled = true;
|
runtimeFields.disabled = true;
|
||||||
businessFields.disabled = true;
|
businessFields.disabled = true;
|
||||||
runtimeSaveState.textContent = "读取成功后可编辑运行参数。";
|
runtimeSaveState.textContent = t("runtime.saveStateInitial");
|
||||||
businessSaveState.textContent = "完成管理会话验证后可填写完整配置。";
|
businessSaveState.textContent = t("business.saveStateInitial");
|
||||||
updateLLMRequirements();
|
updateLLMRequirements();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,7 +638,7 @@
|
||||||
adminSession = "";
|
adminSession = "";
|
||||||
if (expiryTimer) window.clearTimeout(expiryTimer);
|
if (expiryTimer) window.clearTimeout(expiryTimer);
|
||||||
expiryTimer = 0;
|
expiryTimer = 0;
|
||||||
sessionExpiry.textContent = "会话仅保存在当前页面内存";
|
sessionExpiry.textContent = t("session.expiryDefault");
|
||||||
clearForms();
|
clearForms();
|
||||||
clearSecretFields();
|
clearSecretFields();
|
||||||
setBusy("");
|
setBusy("");
|
||||||
|
|
@ -239,22 +653,22 @@
|
||||||
function scheduleSessionExpiry(seconds) {
|
function scheduleSessionExpiry(seconds) {
|
||||||
if (expiryTimer) window.clearTimeout(expiryTimer);
|
if (expiryTimer) window.clearTimeout(expiryTimer);
|
||||||
const duration = Math.max(1, Math.min(Number(seconds) || 900, 900));
|
const duration = Math.max(1, Math.min(Number(seconds) || 900, 900));
|
||||||
sessionExpiry.textContent = `会话约 ${Math.ceil(duration / 60)} 分钟后过期;只保存在页面内存`;
|
sessionExpiry.textContent = t("session.expiry", { n: Math.ceil(duration / 60) });
|
||||||
expiryTimer = window.setTimeout(() => {
|
expiryTimer = window.setTimeout(() => {
|
||||||
expireSession("管理会话已过期,已清除页面中的会话和表单内容。请重新授权。");
|
expireSession(t("session.expired"));
|
||||||
}, duration * 1000);
|
}, duration * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRuntimeConfig(value) {
|
function normalizeRuntimeConfig(value) {
|
||||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||||
throw new Error("配置服务返回了无法识别的运行参数。");
|
throw new Error(t("err.unrecognizedRuntime"));
|
||||||
}
|
}
|
||||||
const config = {};
|
const config = {};
|
||||||
for (const [key] of RUNTIME_FIELDS) {
|
for (const [key] of RUNTIME_FIELDS) {
|
||||||
const item = value[key];
|
const item = value[key];
|
||||||
const limit = RUNTIME_LIMITS[key];
|
const limit = RUNTIME_LIMITS[key];
|
||||||
if (!Number.isInteger(item) || item < limit.min || item > limit.max) {
|
if (!Number.isInteger(item) || item < limit.min || item > limit.max) {
|
||||||
throw new Error("配置服务返回了超出范围的运行参数,请检查后端配置。");
|
throw new Error(t("err.runtimeOutOfRange"));
|
||||||
}
|
}
|
||||||
config[key] = item;
|
config[key] = item;
|
||||||
}
|
}
|
||||||
|
|
@ -269,7 +683,7 @@
|
||||||
fillRuntimeFields(RUNTIME_DEFAULTS);
|
fillRuntimeFields(RUNTIME_DEFAULTS);
|
||||||
runtimeLoaded = false;
|
runtimeLoaded = false;
|
||||||
runtimeFields.disabled = true;
|
runtimeFields.disabled = true;
|
||||||
runtimeSaveState.textContent = "默认值预览;读取后才能保存。";
|
runtimeSaveState.textContent = t("runtime.previewState");
|
||||||
updateButtons();
|
updateButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -277,39 +691,37 @@
|
||||||
fillRuntimeFields(config);
|
fillRuntimeFields(config);
|
||||||
runtimeLoaded = true;
|
runtimeLoaded = true;
|
||||||
runtimeFields.disabled = false;
|
runtimeFields.disabled = false;
|
||||||
runtimeSaveState.textContent = "已读取 Redis 中的运行参数。";
|
runtimeSaveState.textContent = t("runtime.loaded");
|
||||||
runtimeSaveState.classList.remove("is-dirty");
|
runtimeSaveState.classList.remove("is-dirty");
|
||||||
updateButtons();
|
updateButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function runtimeErrorMessage(error, phase) {
|
function runtimeErrorMessage(error, phase) {
|
||||||
if (error.status === 401) return "管理会话已失效,请重新授权。";
|
if (error.status === 401) return t("err.sessionInvalid");
|
||||||
if (error.status === 400) return "运行参数请求格式无效(HTTP 400)。";
|
if (error.status === 400) return t("err.runtime400");
|
||||||
if (error.status === 422) return "运行参数超出允许范围(超时 100–300,000 毫秒,重试 0–5 次)。";
|
if (error.status === 422) return t("err.runtime422");
|
||||||
if (error.status === 503) return phase === "save"
|
if (error.status === 503) return phase === "save" ? t("err.runtime503save") : t("err.runtime503load");
|
||||||
? "Redis 不可用或初始化尚未完成;保存状态无法确认。已禁用运行参数保存,请恢复连接后重新读取。"
|
if (error.status !== undefined) return t("err.runtimeFailStatus", { status: error.status });
|
||||||
: "Redis 不可用或初始化尚未完成。当前只显示默认值预览,运行参数保存已禁用;请稍后重试。";
|
return error.message || t("err.runtimeFail");
|
||||||
if (error.status !== undefined) return `运行参数请求失败(HTTP ${error.status})。请稍后重试。`;
|
|
||||||
return error.message || "运行参数请求失败,请检查服务状态后重试。";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRuntimeConfig() {
|
async function loadRuntimeConfig() {
|
||||||
if (!adminSession || busyAction) return;
|
if (!adminSession || busyAction) return;
|
||||||
showRuntimePreview();
|
showRuntimePreview();
|
||||||
setBusy("runtime-load");
|
setBusy("runtime-load");
|
||||||
showNotice("info", "正在从 Redis 读取运行参数…");
|
showNotice("info", t("runtime.readingToast"));
|
||||||
try {
|
try {
|
||||||
const config = normalizeRuntimeConfig(await request("/api/config"));
|
const config = normalizeRuntimeConfig(await request("/api/config"));
|
||||||
activateRuntimeConfig(config);
|
activateRuntimeConfig(config);
|
||||||
showNotice("success", "运行参数已读取。业务配置仍需手动完整填写。");
|
showNotice("success", t("runtime.readToast"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status === 401) {
|
if (error.status === 401) {
|
||||||
expireSession("管理会话已失效,页面内容已清除。请重新创建会话。");
|
expireSession(t("session.invalid"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
runtimeLoaded = false;
|
runtimeLoaded = false;
|
||||||
runtimeFields.disabled = true;
|
runtimeFields.disabled = true;
|
||||||
runtimeSaveState.textContent = "读取未成功,保存已禁用。";
|
runtimeSaveState.textContent = t("runtime.readFailState");
|
||||||
updateButtons();
|
updateButtons();
|
||||||
showNotice("error", runtimeErrorMessage(error, "load"));
|
showNotice("error", runtimeErrorMessage(error, "load"));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -335,12 +747,12 @@
|
||||||
function parseI64(input, label) {
|
function parseI64(input, label) {
|
||||||
const value = input.value.trim();
|
const value = input.value.trim();
|
||||||
if (!/^-?\d+$/.test(value)) {
|
if (!/^-?\d+$/.test(value)) {
|
||||||
setFieldError(input, `${label} 必须是十进制整数。`);
|
setFieldError(input, t("val.i64", { label }));
|
||||||
throw new Error("invalid-field");
|
throw new Error("invalid-field");
|
||||||
}
|
}
|
||||||
const parsed = BigInt(value);
|
const parsed = BigInt(value);
|
||||||
if (parsed < I64_MIN || parsed > I64_MAX) {
|
if (parsed < I64_MIN || parsed > I64_MAX) {
|
||||||
setFieldError(input, `${label} 超出有符号 64 位整数范围。`);
|
setFieldError(input, t("val.i64Range", { label }));
|
||||||
throw new Error("invalid-field");
|
throw new Error("invalid-field");
|
||||||
}
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
|
|
@ -349,23 +761,23 @@
|
||||||
function parseAllowlist(input) {
|
function parseAllowlist(input) {
|
||||||
const entries = input.value.split(/[\s,]+/).filter(Boolean);
|
const entries = input.value.split(/[\s,]+/).filter(Boolean);
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
setFieldError(input, "至少填写一个 chat ID。空白和逗号均可用作分隔符。");
|
setFieldError(input, t("val.allowlistEmpty"));
|
||||||
throw new Error("invalid-field");
|
throw new Error("invalid-field");
|
||||||
}
|
}
|
||||||
const ids = [];
|
const ids = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!/^-?\d+$/.test(entry)) {
|
if (!/^-?\d+$/.test(entry)) {
|
||||||
setFieldError(input, `白名单中的“${entry}”不是整数 chat ID。`);
|
setFieldError(input, t("val.allowlistNotInt", { entry }));
|
||||||
throw new Error("invalid-field");
|
throw new Error("invalid-field");
|
||||||
}
|
}
|
||||||
const id = BigInt(entry);
|
const id = BigInt(entry);
|
||||||
if (id < I64_MIN || id > I64_MAX) {
|
if (id < I64_MIN || id > I64_MAX) {
|
||||||
setFieldError(input, `白名单中的“${entry}”超出有符号 64 位整数范围。`);
|
setFieldError(input, t("val.allowlistRange", { entry }));
|
||||||
throw new Error("invalid-field");
|
throw new Error("invalid-field");
|
||||||
}
|
}
|
||||||
if (seen.has(id.toString())) {
|
if (seen.has(id.toString())) {
|
||||||
setFieldError(input, `白名单中有重复的 chat ID:${entry}。`);
|
setFieldError(input, t("val.allowlistDup", { entry }));
|
||||||
throw new Error("invalid-field");
|
throw new Error("invalid-field");
|
||||||
}
|
}
|
||||||
seen.add(id.toString());
|
seen.add(id.toString());
|
||||||
|
|
@ -379,20 +791,20 @@
|
||||||
try {
|
try {
|
||||||
parsed = new URL(input.value.trim());
|
parsed = new URL(input.value.trim());
|
||||||
} catch {
|
} catch {
|
||||||
setFieldError(input, `${label} 必须是有效的 HTTPS URL。`);
|
setFieldError(input, t("val.httpsUrl", { label }));
|
||||||
throw new Error("invalid-field");
|
throw new Error("invalid-field");
|
||||||
}
|
}
|
||||||
if (parsed.protocol !== "https:" || !parsed.hostname) {
|
if (parsed.protocol !== "https:" || !parsed.hostname) {
|
||||||
setFieldError(input, `${label} 必须使用 HTTPS。`);
|
setFieldError(input, t("val.https", { label }));
|
||||||
throw new Error("invalid-field");
|
throw new Error("invalid-field");
|
||||||
}
|
}
|
||||||
if (noCredentials && (parsed.username || parsed.password)) {
|
if (noCredentials && (parsed.username || parsed.password)) {
|
||||||
setFieldError(input, `${label} 不能包含用户名或密码。`);
|
setFieldError(input, t("val.noCreds", { label }));
|
||||||
throw new Error("invalid-field");
|
throw new Error("invalid-field");
|
||||||
}
|
}
|
||||||
const rawValue = input.value.trim();
|
const rawValue = input.value.trim();
|
||||||
if (noQuery && (rawValue.includes("?") || rawValue.includes("#"))) {
|
if (noQuery && (rawValue.includes("?") || rawValue.includes("#"))) {
|
||||||
setFieldError(input, `${label} 不能包含查询参数或 #片段。`);
|
setFieldError(input, t("val.noQuery", { label }));
|
||||||
throw new Error("invalid-field");
|
throw new Error("invalid-field");
|
||||||
}
|
}
|
||||||
return input.value.trim();
|
return input.value.trim();
|
||||||
|
|
@ -402,7 +814,7 @@
|
||||||
const input = document.getElementById(id);
|
const input = document.getElementById(id);
|
||||||
const value = secret ? input.value : input.value.trim();
|
const value = secret ? input.value : input.value.trim();
|
||||||
if (isBlank(value)) {
|
if (isBlank(value)) {
|
||||||
setFieldError(input, `${label} 不能为空。`);
|
setFieldError(input, t("val.empty", { label }));
|
||||||
throw new Error("invalid-field");
|
throw new Error("invalid-field");
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
|
|
@ -419,9 +831,9 @@
|
||||||
const llmBaseValue = llmBaseUrl.value.trim();
|
const llmBaseValue = llmBaseUrl.value.trim();
|
||||||
const llmModelValue = llmModel.value.trim();
|
const llmModelValue = llmModel.value.trim();
|
||||||
const llmKeyValue = llmApiKey.value;
|
const llmKeyValue = llmApiKey.value;
|
||||||
if (enabled && !llmBaseValue) return setFieldError(llmBaseUrl, "启用 LLM 时必须填写 Base URL。");
|
if (enabled && !llmBaseValue) return setFieldError(llmBaseUrl, t("val.llmUrl"));
|
||||||
if (enabled && !llmModelValue) return setFieldError(llmModel, "启用 LLM 时必须填写模型名称。 ");
|
if (enabled && !llmModelValue) return setFieldError(llmModel, t("val.llmModel"));
|
||||||
if (enabled && isBlank(llmKeyValue)) return setFieldError(llmApiKey, "启用 LLM 时必须填写 API key。");
|
if (enabled && isBlank(llmKeyValue)) return setFieldError(llmApiKey, t("val.llmKey"));
|
||||||
const llmBase = llmBaseValue
|
const llmBase = llmBaseValue
|
||||||
? (enabled ? validateHttpsUrl(llmBaseUrl, "LLM Base URL") : llmBaseValue)
|
? (enabled ? validateHttpsUrl(llmBaseUrl, "LLM Base URL") : llmBaseValue)
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -429,13 +841,12 @@
|
||||||
const jmapUrlInput = document.getElementById("jmap-session-url");
|
const jmapUrlInput = document.getElementById("jmap-session-url");
|
||||||
const business = {
|
const business = {
|
||||||
bot_token: requireText("telegram-bot-token", "Telegram Bot Token", true),
|
bot_token: requireText("telegram-bot-token", "Telegram Bot Token", true),
|
||||||
telegram_chat_id: parseI64(document.getElementById("telegram-chat-id"), "目标 Chat ID"),
|
telegram_chat_id: parseI64(document.getElementById("telegram-chat-id"), t("telegram.chatLabel")),
|
||||||
chat_allowlist: parseAllowlist(document.getElementById("chat-allowlist")),
|
chat_allowlist: parseAllowlist(document.getElementById("chat-allowlist")),
|
||||||
telegram_webhook_secret: requireText("telegram-webhook-secret", "Telegram Webhook Secret", true),
|
telegram_webhook_secret: requireText("telegram-webhook-secret", "Telegram Webhook Secret", true),
|
||||||
jmap_session_url: validateHttpsUrl(jmapUrlInput, "JMAP Session URL", { noCredentials: true, noQuery: true }),
|
jmap_session_url: validateHttpsUrl(jmapUrlInput, "JMAP Session URL", { noCredentials: true, noQuery: true }),
|
||||||
jmap_username: requireText("jmap-username", "邮箱用户名"),
|
jmap_username: requireText("jmap-username", t("jmap.userLabel")),
|
||||||
jmap_password: requireText("jmap-password", "JMAP 应用专用密码", true),
|
jmap_password: requireText("jmap-password", t("jmap.passLabel"), true),
|
||||||
jmap_push_verification: requireText("jmap-push-verification", "JMAP Push 验证码", true),
|
|
||||||
account_id: document.getElementById("account-id").value.trim() || null,
|
account_id: document.getElementById("account-id").value.trim() || null,
|
||||||
llm_enabled: enabled,
|
llm_enabled: enabled,
|
||||||
llm_allow_net: document.getElementById("llm-allow-net").checked,
|
llm_allow_net: document.getElementById("llm-allow-net").checked,
|
||||||
|
|
@ -462,12 +873,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function businessErrorMessage(error) {
|
function businessErrorMessage(error) {
|
||||||
if (error.status === 401) return "管理会话已失效。页面已清除会话和表单内容,请重新授权。";
|
if (error.status === 401) return t("berr.401");
|
||||||
if (error.status === 400) return "后端无法解析提交的 JSON(HTTP 400);请检查字段后重试。";
|
if (error.status === 400) return t("berr.400");
|
||||||
if (error.status === 422) return "业务配置校验失败(HTTP 422)。请检查必填项、HTTPS URL、LLM 设置和 chat ID 白名单。服务端没有返回密钥或错误详情。";
|
if (error.status === 422) return t("berr.422");
|
||||||
if (error.status === 503) return "业务配置未获成功确认(HTTP 503)。Redis 可能尚未就绪,或后端无法建立 JMAP/LLM 客户端;页面保留本次输入供检查和重试。";
|
if (error.status === 503) return t("berr.503");
|
||||||
if (error.status !== undefined) return `业务配置请求失败(HTTP ${error.status})。请稍后重试。`;
|
if (error.status !== undefined) return t("berr.status", { status: error.status });
|
||||||
return `${error.message || "业务配置请求失败。"} 请求结果可能无法确认;检查后可重新提交完整配置。`;
|
return t("berr.fail", { message: error.message || t("berr.failDefault") });
|
||||||
}
|
}
|
||||||
|
|
||||||
function runtimePayloadFromForm() {
|
function runtimePayloadFromForm() {
|
||||||
|
|
@ -477,9 +888,9 @@
|
||||||
const input = document.getElementById(id);
|
const input = document.getElementById(id);
|
||||||
const value = input.value.trim() === "" ? Number.NaN : Number(input.value);
|
const value = input.value.trim() === "" ? Number.NaN : Number(input.value);
|
||||||
const limit = RUNTIME_LIMITS[key];
|
const limit = RUNTIME_LIMITS[key];
|
||||||
if (!Number.isInteger(value)) return setFieldError(input, "请输入整数。"), null;
|
if (!Number.isInteger(value)) return setFieldError(input, t("err.invalidInt")), null;
|
||||||
if (value < limit.min || value > limit.max) {
|
if (value < limit.min || value > limit.max) {
|
||||||
return setFieldError(input, `请输入 ${limit.min} 到 ${limit.max} 之间的整数。`), null;
|
return setFieldError(input, t("err.rangeInt", { min: limit.min, max: limit.max })), null;
|
||||||
}
|
}
|
||||||
config[key] = value;
|
config[key] = value;
|
||||||
}
|
}
|
||||||
|
|
@ -491,9 +902,10 @@
|
||||||
llmApiKey.required = enabled;
|
llmApiKey.required = enabled;
|
||||||
llmBaseUrl.required = enabled;
|
llmBaseUrl.required = enabled;
|
||||||
llmModel.required = enabled;
|
llmModel.required = enabled;
|
||||||
document.querySelector("#llm-key-required").textContent = enabled ? "必填" : "可选";
|
const mark = enabled ? t("common.required") : t("common.optional");
|
||||||
document.querySelector("#llm-url-required").textContent = enabled ? "必填" : "可选";
|
document.querySelector("#llm-key-required").textContent = mark;
|
||||||
document.querySelector("#llm-model-required").textContent = enabled ? "必填" : "可选";
|
document.querySelector("#llm-url-required").textContent = mark;
|
||||||
|
document.querySelector("#llm-model-required").textContent = mark;
|
||||||
}
|
}
|
||||||
|
|
||||||
authForm.addEventListener("submit", async (event) => {
|
authForm.addEventListener("submit", async (event) => {
|
||||||
|
|
@ -502,36 +914,36 @@
|
||||||
let bootstrapCredential = bootstrapInput.value;
|
let bootstrapCredential = bootstrapInput.value;
|
||||||
bootstrapInput.value = "";
|
bootstrapInput.value = "";
|
||||||
if (isBlank(bootstrapCredential)) {
|
if (isBlank(bootstrapCredential)) {
|
||||||
showNotice("error", "请输入 Redis ACL 管理凭据。输入不会写入浏览器存储。");
|
showNotice("error", t("login.enterCred"));
|
||||||
bootstrapInput.focus();
|
bootstrapInput.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setBusy("login");
|
setBusy("login");
|
||||||
showNotice("info", "正在创建短期管理会话…");
|
showNotice("info", t("login.creatingToast"));
|
||||||
try {
|
try {
|
||||||
const sessionInfo = await request("/api/admin/session", "POST", undefined, bootstrapCredential);
|
const sessionInfo = await request("/api/admin/session", "POST", undefined, bootstrapCredential);
|
||||||
bootstrapCredential = "";
|
bootstrapCredential = "";
|
||||||
if (!sessionInfo || typeof sessionInfo.session !== "string" || !sessionInfo.session) {
|
if (!sessionInfo || typeof sessionInfo.session !== "string" || !sessionInfo.session) {
|
||||||
throw new Error("管理接口返回了无效的会话响应。");
|
throw new Error(t("login.invalidResponse"));
|
||||||
}
|
}
|
||||||
adminSession = sessionInfo.session;
|
adminSession = sessionInfo.session;
|
||||||
scheduleSessionExpiry(sessionInfo.expires_in);
|
scheduleSessionExpiry(sessionInfo.expires_in);
|
||||||
showConfigView();
|
showConfigView();
|
||||||
businessFields.disabled = false;
|
businessFields.disabled = false;
|
||||||
businessSaveState.textContent = "完整填写 BusinessConfigWire 后可提交。密钥不会回显。";
|
businessSaveState.textContent = t("business.saveStateReady");
|
||||||
showRuntimePreview();
|
showRuntimePreview();
|
||||||
setBusy("");
|
setBusy("");
|
||||||
showNotice("success", "管理会话已创建。运行参数正在读取;业务配置需要完整重新填写。 ");
|
showNotice("success", t("login.successToast"));
|
||||||
await loadRuntimeConfig();
|
await loadRuntimeConfig();
|
||||||
await loadEnabled();
|
await loadEnabled();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
bootstrapCredential = "";
|
bootstrapCredential = "";
|
||||||
setBusy("");
|
setBusy("");
|
||||||
if (error.status === 401) showNotice("error", "Redis 管理凭据无效,或服务尚未启用管理会话。请检查 Redis URL ACL 密码。 ");
|
if (error.status === 401) showNotice("error", t("login.401"));
|
||||||
else if (error.status === 503) showNotice("error", "Redis 不可用或初始化尚未完成,暂时无法创建管理会话。请稍后重试。 ");
|
else if (error.status === 503) showNotice("error", t("login.503"));
|
||||||
else if (error.status !== undefined) showNotice("error", `无法创建管理会话(HTTP ${error.status})。`);
|
else if (error.status !== undefined) showNotice("error", t("login.status", { status: error.status }));
|
||||||
else showNotice("error", error.message || "无法连接管理服务,请检查网络后重试。");
|
else showNotice("error", error.message || t("login.fail"));
|
||||||
bootstrapInput.focus();
|
bootstrapInput.focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -544,12 +956,12 @@
|
||||||
serviceEnabled.disabled = true;
|
serviceEnabled.disabled = true;
|
||||||
try {
|
try {
|
||||||
await request("/api/enabled", "PUT", { enabled: desired });
|
await request("/api/enabled", "PUT", { enabled: desired });
|
||||||
serviceEnabledState.textContent = desired ? "业务入口已启用" : "业务入口已关闭(fail-closed)";
|
serviceEnabledState.textContent = desired ? t("service.enabled") : t("service.disabled");
|
||||||
showNotice("success", desired ? "业务处理已启用。" : "业务处理已关闭,入口将返回未启用状态。 ");
|
showNotice("success", desired ? t("service.enabledToast") : t("service.disabledToast"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
serviceEnabled.checked = !desired;
|
serviceEnabled.checked = !desired;
|
||||||
serviceEnabledState.textContent = serviceEnabled.checked ? "业务入口已启用" : "业务入口已关闭(fail-closed)";
|
serviceEnabledState.textContent = serviceEnabled.checked ? t("service.enabled") : t("service.disabled");
|
||||||
showNotice("error", error.status === 401 ? "管理会话已失效,请重新授权。" : "无法保存业务开关状态。 ");
|
showNotice("error", error.status === 401 ? t("err.sessionInvalid") : t("service.saveFail"));
|
||||||
} finally {
|
} finally {
|
||||||
serviceEnabled.disabled = false;
|
serviceEnabled.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
@ -565,7 +977,7 @@
|
||||||
// Local credentials are cleared even when the revocation request cannot reach Redis.
|
// Local credentials are cleared even when the revocation request cannot reach Redis.
|
||||||
}
|
}
|
||||||
clearSession();
|
clearSession();
|
||||||
showNotice("info", "已退出;管理会话和表单中的密钥已从页面内存清除。");
|
showNotice("info", t("session.revoked"));
|
||||||
});
|
});
|
||||||
|
|
||||||
statusRetryButton.addEventListener("click", showSetupStatus);
|
statusRetryButton.addEventListener("click", showSetupStatus);
|
||||||
|
|
@ -573,22 +985,22 @@
|
||||||
defaultsButton.addEventListener("click", () => {
|
defaultsButton.addEventListener("click", () => {
|
||||||
if (!runtimeLoaded || busyAction) return;
|
if (!runtimeLoaded || busyAction) return;
|
||||||
fillRuntimeFields(RUNTIME_DEFAULTS);
|
fillRuntimeFields(RUNTIME_DEFAULTS);
|
||||||
runtimeSaveState.textContent = "已恢复默认预览值;保存后才会写入 Redis。";
|
runtimeSaveState.textContent = t("runtime.defaultsState");
|
||||||
runtimeSaveState.classList.add("is-dirty");
|
runtimeSaveState.classList.add("is-dirty");
|
||||||
showNotice("info", "运行参数已恢复为默认值预览。点击“保存运行参数”后才会更新 Redis。");
|
showNotice("info", t("runtime.defaultsToast"));
|
||||||
});
|
});
|
||||||
|
|
||||||
businessForm.addEventListener("input", (event) => {
|
businessForm.addEventListener("input", (event) => {
|
||||||
event.target.setCustomValidity("");
|
event.target.setCustomValidity("");
|
||||||
if (!adminSession) return;
|
if (!adminSession) return;
|
||||||
businessSaveState.textContent = "表单包含未提交内容;每次提交会完整替换现有业务配置。";
|
businessSaveState.textContent = t("business.dirty");
|
||||||
businessSaveState.classList.add("is-dirty");
|
businessSaveState.classList.add("is-dirty");
|
||||||
});
|
});
|
||||||
|
|
||||||
runtimeForm.addEventListener("input", (event) => {
|
runtimeForm.addEventListener("input", (event) => {
|
||||||
event.target.setCustomValidity("");
|
event.target.setCustomValidity("");
|
||||||
if (!runtimeLoaded) return;
|
if (!runtimeLoaded) return;
|
||||||
runtimeSaveState.textContent = "有尚未保存的运行参数修改。";
|
runtimeSaveState.textContent = t("runtime.dirty");
|
||||||
runtimeSaveState.classList.add("is-dirty");
|
runtimeSaveState.classList.add("is-dirty");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -600,20 +1012,20 @@
|
||||||
const config = readBusinessConfig();
|
const config = readBusinessConfig();
|
||||||
if (!config) return;
|
if (!config) return;
|
||||||
setBusy("business-save");
|
setBusy("business-save");
|
||||||
showNotice("info", "正在提交完整业务配置;后端会先构建客户端,再保存并切换运行实例…");
|
showNotice("info", t("business.submittingToast"));
|
||||||
try {
|
try {
|
||||||
await request("/api/business-config", "PUT", stringifyWithBigInt(config));
|
await request("/api/business-config", "PUT", stringifyWithBigInt(config));
|
||||||
clearSecretFields();
|
clearSecretFields();
|
||||||
businessSaveState.textContent = "业务配置已保存,新配置已热加载。";
|
businessSaveState.textContent = t("business.saved");
|
||||||
businessSaveState.classList.remove("is-dirty");
|
businessSaveState.classList.remove("is-dirty");
|
||||||
showNotice("success", "业务配置已保存并热加载。密钥输入已清空;下次完整替换时需要重新填写。");
|
showNotice("success", t("business.savedToast"));
|
||||||
updateButtons();
|
updateButtons();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status === 401) {
|
if (error.status === 401) {
|
||||||
expireSession("管理会话已失效,已清除页面中的会话与配置。请重新授权。");
|
expireSession(t("session.invalidBusiness"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
businessSaveState.textContent = "未收到保存成功确认;请检查服务状态。";
|
businessSaveState.textContent = t("business.unsaved");
|
||||||
showNotice("error", businessErrorMessage(error));
|
showNotice("error", businessErrorMessage(error));
|
||||||
} finally {
|
} finally {
|
||||||
if (adminSession) setBusy("");
|
if (adminSession) setBusy("");
|
||||||
|
|
@ -626,20 +1038,20 @@
|
||||||
const config = runtimePayloadFromForm();
|
const config = runtimePayloadFromForm();
|
||||||
if (!config) return;
|
if (!config) return;
|
||||||
setBusy("runtime-save");
|
setBusy("runtime-save");
|
||||||
showNotice("info", "正在保存运行参数到 Redis…");
|
showNotice("info", t("runtime.savingToast"));
|
||||||
try {
|
try {
|
||||||
const saved = normalizeRuntimeConfig(await request("/api/config", "PUT", config));
|
const saved = normalizeRuntimeConfig(await request("/api/config", "PUT", config));
|
||||||
activateRuntimeConfig(saved);
|
activateRuntimeConfig(saved);
|
||||||
showNotice("success", "运行参数已保存到 Redis,并立即应用于后续请求。");
|
showNotice("success", t("runtime.savedToast"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status === 401) {
|
if (error.status === 401) {
|
||||||
expireSession("管理会话已失效,已清除页面中的会话与配置。请重新授权。");
|
expireSession(t("session.invalidBusiness"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (error.status >= 500 || error.status === undefined) {
|
if (error.status >= 500 || error.status === undefined) {
|
||||||
runtimeLoaded = false;
|
runtimeLoaded = false;
|
||||||
runtimeFields.disabled = true;
|
runtimeFields.disabled = true;
|
||||||
runtimeSaveState.textContent = "保存状态无法确认;重新读取成功前已禁用保存。";
|
runtimeSaveState.textContent = t("runtime.saveUnconfirmed");
|
||||||
updateButtons();
|
updateButtons();
|
||||||
}
|
}
|
||||||
showNotice("error", runtimeErrorMessage(error, "save"));
|
showNotice("error", runtimeErrorMessage(error, "save"));
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ async function startPage(statusResponse) {
|
||||||
vm.runInNewContext(source, { document, window, fetch, Headers });
|
vm.runInNewContext(source, { document, window, fetch, Headers });
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
return { elements, fetchCalls };
|
return { elements, fetchCalls, window };
|
||||||
}
|
}
|
||||||
|
|
||||||
test("configuration-setup hides all authorization UI and lists only missing variable names", async () => {
|
test("configuration-setup hides all authorization UI and lists only missing variable names", async () => {
|
||||||
|
|
@ -103,8 +103,8 @@ test("configuration-setup hides all authorization UI and lists only missing vari
|
||||||
assert.equal(elements.get("config-panel").hidden, true);
|
assert.equal(elements.get("config-panel").hidden, true);
|
||||||
assert.equal(elements.get("notice").hidden, true);
|
assert.equal(elements.get("notice").hidden, true);
|
||||||
assert.equal(elements.get("privacy-note").hidden, true);
|
assert.equal(elements.get("privacy-note").hidden, true);
|
||||||
assert.equal(elements.get("setup-missing").textContent, "REDIS_URL、CONFIG_ENCRYPTION_KEY");
|
assert.equal(elements.get("setup-missing").textContent, "REDIS_URL, CONFIG_ENCRYPTION_KEY");
|
||||||
assert.equal(elements.get("setup-message").textContent.includes("配置引导模式"), true);
|
assert.equal(elements.get("setup-message").textContent.includes("setup mode"), true);
|
||||||
assert.equal(fetchCalls.length, 1);
|
assert.equal(fetchCalls.length, 1);
|
||||||
assert.equal(fetchCalls[0].path, "/api/status");
|
assert.equal(fetchCalls[0].path, "/api/status");
|
||||||
assert.equal(fetchCalls[0].options.credentials, "omit");
|
assert.equal(fetchCalls[0].options.credentials, "omit");
|
||||||
|
|
@ -134,7 +134,7 @@ test("status errors keep the authorization form hidden until readiness is confir
|
||||||
assert.equal(elements.get("setup-card").hidden, false);
|
assert.equal(elements.get("setup-card").hidden, false);
|
||||||
assert.equal(elements.get("auth-card").hidden, true);
|
assert.equal(elements.get("auth-card").hidden, true);
|
||||||
assert.equal(elements.get("config-panel").hidden, true);
|
assert.equal(elements.get("config-panel").hidden, true);
|
||||||
assert.match(elements.get("setup-message").textContent, /授权入口保持隐藏/);
|
assert.match(elements.get("setup-message").textContent, /authorization entry stays hidden/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("setup card inherits desktop layout width and follows the existing mobile layout width", () => {
|
test("setup card inherits desktop layout width and follows the existing mobile layout width", () => {
|
||||||
|
|
@ -146,3 +146,47 @@ test("setup card inherits desktop layout width and follows the existing mobile l
|
||||||
assert.match(styles, /@media \(max-width: 720px\)\s*\{[\s\S]*?\.layout\s*\{[^}]*width:\s*calc\(100% - 32px\)/);
|
assert.match(styles, /@media \(max-width: 720px\)\s*\{[\s\S]*?\.layout\s*\{[^}]*width:\s*calc\(100% - 32px\)/);
|
||||||
assert.match(styles, /\.auth-panel\s*\{[^}]*padding:\s*27px 29px 25px/);
|
assert.match(styles, /\.auth-panel\s*\{[^}]*padding:\s*27px 29px 25px/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("i18n defaults to English and keeps the zh/en dictionaries in key parity", async () => {
|
||||||
|
const { window } = await startPage({
|
||||||
|
body: { ready: true, mode: "configured", missing: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { detectLocale, getLocale, t, messages } = window.__mw;
|
||||||
|
assert.equal(detectLocale(), "en");
|
||||||
|
assert.equal(getLocale(), "en");
|
||||||
|
|
||||||
|
const enKeys = Object.keys(messages.en).sort();
|
||||||
|
const zhKeys = Object.keys(messages.zh).sort();
|
||||||
|
assert.deepEqual(enKeys, zhKeys);
|
||||||
|
assert.ok(enKeys.length > 100, `expected a broad message catalog, got ${enKeys.length}`);
|
||||||
|
|
||||||
|
assert.equal(t("common.required"), "Required");
|
||||||
|
assert.equal(
|
||||||
|
t("session.expiry", { n: 5 }),
|
||||||
|
"Admin session expires in about 5 minutes; kept only in page memory",
|
||||||
|
);
|
||||||
|
assert.equal(t("missing.key"), "missing.key");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every data-i18n key in the markup resolves in both dictionaries", async () => {
|
||||||
|
const { window } = await startPage({
|
||||||
|
body: { ready: true, mode: "configured", missing: [] },
|
||||||
|
});
|
||||||
|
const { messages } = window.__mw;
|
||||||
|
|
||||||
|
const keys = new Set();
|
||||||
|
for (const match of html.matchAll(/data-i18n(?:-placeholder|-aria)?="([^"]+)"/g)) {
|
||||||
|
keys.add(match[1]);
|
||||||
|
}
|
||||||
|
assert.ok(keys.size > 40, `expected many i18n keys in the markup, got ${keys.size}`);
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
assert.ok(key in messages.en, `markup key missing from en dictionary: ${key}`);
|
||||||
|
assert.ok(key in messages.zh, `markup key missing from zh dictionary: ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.match(html, /<html lang="en">/);
|
||||||
|
assert.match(html, /data-i18n-placeholder="auth\.placeholder"/);
|
||||||
|
assert.match(html, /data-i18n-aria="brand\.aria"/);
|
||||||
|
});
|
||||||
|
|
|
||||||
124
web/index.html
124
web/index.html
|
|
@ -1,11 +1,11 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="color-scheme" content="light" />
|
<meta name="color-scheme" content="light" />
|
||||||
<meta name="referrer" content="no-referrer" />
|
<meta name="referrer" content="no-referrer" />
|
||||||
<title>业务配置 · MessageWeave</title>
|
<title data-i18n="app.title">Service Configuration · MessageWeave</title>
|
||||||
<link rel="stylesheet" href="/assets/styles.css" />
|
<link rel="stylesheet" href="/assets/styles.css" />
|
||||||
<script src="/assets/config.js" defer></script>
|
<script src="/assets/config.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -14,22 +14,22 @@
|
||||||
<div class="ambient ambient-two" aria-hidden="true"></div>
|
<div class="ambient ambient-two" aria-hidden="true"></div>
|
||||||
|
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a class="brand" href="/" aria-label="MessageWeave 配置首页">
|
<a class="brand" href="/" data-i18n-aria="brand.aria" aria-label="MessageWeave configuration home">
|
||||||
<span class="brand-mark" aria-hidden="true">S</span>
|
<span class="brand-mark" aria-hidden="true">S</span>
|
||||||
<span class="brand-copy"><strong>MessageWeave</strong><span>业务配置</span></span>
|
<span class="brand-copy"><strong>MessageWeave</strong><span data-i18n="brand.tagline">Service configuration</span></span>
|
||||||
</a>
|
</a>
|
||||||
<span class="topbar-note"><span class="secure-dot"></span>安全配置通道</span>
|
<span class="topbar-note"><span class="secure-dot"></span><span data-i18n="topbar.note">Secure configuration channel</span></span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="layout">
|
<main class="layout">
|
||||||
<section id="intro" class="intro" aria-labelledby="page-title" hidden>
|
<section id="intro" class="intro" aria-labelledby="page-title" hidden>
|
||||||
<p class="eyebrow">服务管理 <span>·</span> CONFIGURATION</p>
|
<p class="eyebrow"><span data-i18n="intro.eyebrow">Service management</span> <span>·</span> CONFIGURATION</p>
|
||||||
<h1 id="page-title">管理你的服务连接</h1>
|
<h1 id="page-title" data-i18n="intro.title">Manage your service connections</h1>
|
||||||
<p class="intro-copy">配置 JMAP、Telegram、LLM、访问白名单和运行参数。业务配置保存在外部 Redis,成功保存后会立即热加载。</p>
|
<p class="intro-copy" data-i18n="intro.copy">Configure JMAP, Telegram, LLM, access allowlist and runtime parameters. Business configuration is stored in external Redis and hot-reloaded immediately after a successful save.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div id="notice" class="notice notice-info" role="status" aria-live="polite" hidden>
|
<div id="notice" class="notice notice-info" role="status" aria-live="polite" hidden data-i18n="notice.bootstrap">
|
||||||
使用 Redis 管理凭据创建短期管理会话。
|
Create a short-lived admin session using Redis management credentials.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section id="setup-card" class="panel auth-panel setup-panel" aria-labelledby="setup-title" aria-live="polite">
|
<section id="setup-card" class="panel auth-panel setup-panel" aria-labelledby="setup-title" aria-live="polite">
|
||||||
|
|
@ -37,12 +37,12 @@
|
||||||
<div class="icon-tile icon-violet" aria-hidden="true">
|
<div class="icon-tile icon-violet" aria-hidden="true">
|
||||||
<svg viewBox="0 0 24 24"><path d="M12 3 4 7v5c0 4.4 3.1 7.6 8 9 4.9-1.4 8-4.6 8-9V7l-8-4Z"/><path d="M12 8v5M12 16h.01"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M12 3 4 7v5c0 4.4 3.1 7.6 8 9 4.9-1.4 8-4.6 8-9V7l-8-4Z"/><path d="M12 8v5M12 16h.01"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div><p class="eyebrow">配置引导</p><h2 id="setup-title">服务启动配置</h2></div>
|
<div><p class="eyebrow" data-i18n="setup.eyebrow">Setup guide</p><h2 id="setup-title" data-i18n="setup.title">Service startup configuration</h2></div>
|
||||||
</div>
|
</div>
|
||||||
<p id="setup-message" class="panel-copy">正在检查服务启动配置…</p>
|
<p id="setup-message" class="panel-copy" data-i18n="setup.checking">Checking service startup configuration…</p>
|
||||||
<p id="setup-missing" class="setup-missing" hidden></p>
|
<p id="setup-missing" class="setup-missing" hidden></p>
|
||||||
<p class="setup-privacy">启动所需密钥请在部署环境中配置。密钥不会在页面保存或回显。</p>
|
<p class="setup-privacy" data-i18n="setup.privacy">Startup secrets must be configured in the deployment environment. Secrets are never saved or echoed in the page.</p>
|
||||||
<button id="status-retry-button" class="button button-quiet" type="button">重新检查</button>
|
<button id="status-retry-button" class="button button-quiet" type="button" data-i18n="setup.retry">Re-check</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="auth-card" class="panel auth-panel" aria-labelledby="auth-title" hidden>
|
<section id="auth-card" class="panel auth-panel" aria-labelledby="auth-title" hidden>
|
||||||
|
|
@ -50,113 +50,113 @@
|
||||||
<div class="icon-tile icon-violet" aria-hidden="true">
|
<div class="icon-tile icon-violet" aria-hidden="true">
|
||||||
<svg viewBox="0 0 24 24"><path d="M7.5 10V7.5a4.5 4.5 0 0 1 9 0V10M6 10h12v10H6z"/><path d="M12 14v2"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M7.5 10V7.5a4.5 4.5 0 0 1 9 0V10M6 10h12v10H6z"/><path d="M12 14v2"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div><p class="eyebrow">需要授权</p><h2 id="auth-title">创建管理会话</h2></div>
|
<div><p class="eyebrow" data-i18n="auth.eyebrow">Authorization required</p><h2 id="auth-title" data-i18n="auth.title">Create admin session</h2></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="panel-copy">输入部署时 <code>REDIS_URL</code> 中 Redis ACL 用户的密码。服务端会签发有效期 15 分钟的管理会话;密码和会话只在当前页面内存中使用。</p>
|
<p class="panel-copy"><span data-i18n="auth.bodyPre">Enter the password of the Redis ACL user from the deployment </span><code>REDIS_URL</code><span data-i18n="auth.bodyPost">. The server issues a 15-minute admin session; the password and session exist only in the current page memory.</span></p>
|
||||||
<form id="auth-form" class="auth-form" autocomplete="off">
|
<form id="auth-form" class="auth-form" autocomplete="off">
|
||||||
<label for="bootstrap-input">Redis 管理凭据</label>
|
<label for="bootstrap-input" data-i18n="auth.label">Redis management credential</label>
|
||||||
<div class="token-row">
|
<div class="token-row">
|
||||||
<input id="bootstrap-input" name="bootstrap-credential" type="password" autocomplete="new-password" spellcheck="false" required placeholder="输入 Redis URL 中的 ACL 密码" aria-describedby="bootstrap-hint" />
|
<input id="bootstrap-input" name="bootstrap-credential" type="password" autocomplete="new-password" spellcheck="false" required data-i18n-placeholder="auth.placeholder" placeholder="Enter the ACL password from the Redis URL" aria-describedby="bootstrap-hint" />
|
||||||
<button id="connect-button" class="button button-primary" type="submit">创建会话</button>
|
<button id="connect-button" class="button button-primary" type="submit" data-i18n="auth.submit">Create session</button>
|
||||||
</div>
|
</div>
|
||||||
<p id="bootstrap-hint" class="field-hint">不会写入 localStorage、sessionStorage 或 Cookie。关闭或刷新页面后需要重新授权。</p>
|
<p id="bootstrap-hint" class="field-hint" data-i18n="auth.hint">Never written to localStorage, sessionStorage or cookies. You must re-authorize after closing or refreshing the page.</p>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="config-panel" class="config-panel" aria-label="业务配置与运行参数" hidden>
|
<section id="config-panel" class="config-panel" data-i18n-aria="config.aria" aria-label="Business configuration and runtime parameters" hidden>
|
||||||
<div class="panel config-toolbar">
|
<div class="panel config-toolbar">
|
||||||
<div class="session-state"><span class="status-pulse" aria-hidden="true"></span><div><strong>管理会话有效</strong><span id="session-expiry">会话仅保存在当前页面内存</span></div></div>
|
<div class="session-state"><span class="status-pulse" aria-hidden="true"></span><div><strong data-i18n="session.active">Admin session active</strong><span id="session-expiry" data-i18n="session.expiryDefault">Session kept only in current page memory</span></div></div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<label class="toggle-card" for="service-enabled"><input id="service-enabled" type="checkbox" /><span><strong>启用业务处理</strong><small id="service-enabled-state">读取中…</small></span></label>
|
<label class="toggle-card" for="service-enabled"><input id="service-enabled" type="checkbox" /><span><strong data-i18n="service.enable">Enable business processing</strong><small id="service-enabled-state" data-i18n="service.loading">Loading…</small></span></label>
|
||||||
<button id="reload-button" class="button button-quiet" type="button">重新读取运行参数</button>
|
<button id="reload-button" class="button button-quiet" type="button" data-i18n="reload.button">Reload runtime parameters</button>
|
||||||
<button id="logout-button" class="button button-quiet" type="button">退出并清除会话</button>
|
<button id="logout-button" class="button button-quiet" type="button" data-i18n="logout.button">Sign out and clear session</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="panel config-section" aria-labelledby="business-title">
|
<section class="panel config-section" aria-labelledby="business-title">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div><p class="eyebrow">完整替换</p><h2 id="business-title">业务连接与鉴权</h2></div>
|
<div><p class="eyebrow" data-i18n="business.eyebrow">Full replacement</p><h2 id="business-title" data-i18n="business.title">Business connections and auth</h2></div>
|
||||||
<span class="persist-label"><span class="persist-dot"></span>Redis 加密保存</span>
|
<span class="persist-label"><span class="persist-dot"></span><span data-i18n="business.persist">Encrypted in Redis</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="callout callout-warning">
|
<div class="callout callout-warning">
|
||||||
<strong>业务配置不会从服务器读取。</strong>为避免密钥回显,API 不提供业务配置 GET。每次提交都必须重新填写完整配置,包括所有必填密钥;可选的 account ID 与 LLM 字段留空时会发送为 <code>null</code>。
|
<strong data-i18n="callout.title">Business configuration is not read from the server.</strong>
|
||||||
|
<span data-i18n="callout.bodyPre"> To avoid secret echo, the API does not expose a business configuration GET. Every submission must re-fill the complete configuration, including all required secrets; optional account ID and LLM fields are sent as </span><code>null</code><span data-i18n="callout.bodyPost"> when left blank.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="business-form" novalidate>
|
<form id="business-form" novalidate>
|
||||||
<fieldset id="business-fields" disabled>
|
<fieldset id="business-fields" disabled>
|
||||||
<legend class="visually-hidden">完整业务配置</legend>
|
<legend class="visually-hidden" data-i18n="legend.business">Full business configuration</legend>
|
||||||
|
|
||||||
<section class="form-section" aria-labelledby="jmap-title">
|
<section class="form-section" aria-labelledby="jmap-title">
|
||||||
<div class="form-section-heading"><span class="section-number">01</span><div><h3 id="jmap-title">JMAP 邮箱</h3><p>服务端必须使用 HTTPS,URL 中不能包含凭据、查询参数或片段。</p></div></div>
|
<div class="form-section-heading"><span class="section-number">01</span><div><h3 id="jmap-title" data-i18n="jmap.title">JMAP mailbox</h3><p data-i18n="jmap.desc">The server must use HTTPS; the URL must not contain credentials, query parameters or fragments.</p></div></div>
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<div class="field field-wide"><label for="jmap-session-url">JMAP Session URL <span class="required-mark">必填</span></label><input id="jmap-session-url" name="jmap_session_url" type="url" inputmode="url" autocomplete="url" required placeholder="https://mail.example.com/.well-known/jmap" /><small>HTTPS 地址,不含用户名、密码、查询参数或 #片段。</small></div>
|
<div class="field field-wide"><label for="jmap-session-url">JMAP Session URL <span class="required-mark" data-i18n="common.required">Required</span></label><input id="jmap-session-url" name="jmap_session_url" type="url" inputmode="url" autocomplete="url" required placeholder="https://mail.example.com/.well-known/jmap" /><small data-i18n="jmap.urlHint">HTTPS address without username, password, query parameters or #fragment.</small></div>
|
||||||
<div class="field"><label for="jmap-username">邮箱用户名 <span class="required-mark">必填</span></label><input id="jmap-username" name="jmap_username" type="text" autocomplete="username" required /></div>
|
<div class="field"><label for="jmap-username"><span data-i18n="jmap.userLabel">Email username</span> <span class="required-mark" data-i18n="common.required">Required</span></label><input id="jmap-username" name="jmap_username" type="text" autocomplete="username" required /></div>
|
||||||
<div class="field"><label for="jmap-password">应用专用密码 <span class="required-mark">必填</span></label><input id="jmap-password" name="jmap_password" type="password" autocomplete="new-password" spellcheck="false" required placeholder="每次完整保存时重新输入" /></div>
|
<div class="field"><label for="jmap-password"><span data-i18n="jmap.passLabel">App-specific password</span> <span class="required-mark" data-i18n="common.required">Required</span></label><input id="jmap-password" name="jmap_password" type="password" autocomplete="new-password" spellcheck="false" required data-i18n-placeholder="secret.reenter" placeholder="Re-enter on each full save" /></div>
|
||||||
<div class="field"><label for="account-id">JMAP Account ID <span class="optional-mark">可选</span></label><input id="account-id" name="account_id" type="text" autocomplete="off" placeholder="留空使用 session 主账户" /></div>
|
<div class="field"><label for="account-id">JMAP Account ID <span class="optional-mark" data-i18n="common.optional">Optional</span></label><input id="account-id" name="account_id" type="text" autocomplete="off" data-i18n-placeholder="jmap.accountPh" placeholder="Leave blank to use the session primary account" /></div>
|
||||||
<div class="field"><label for="jmap-push-verification">JMAP Push 验证码 <span class="required-mark">必填</span></label><input id="jmap-push-verification" name="jmap_push_verification" type="password" autocomplete="new-password" spellcheck="false" required placeholder="每次完整保存时重新输入" /></div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="form-section" aria-labelledby="telegram-title">
|
<section class="form-section" aria-labelledby="telegram-title">
|
||||||
<div class="form-section-heading"><span class="section-number">02</span><div><h3 id="telegram-title">Telegram 与访问鉴权</h3><p>仅允许白名单中的 chat ID 触发机器人操作。</p></div></div>
|
<div class="form-section-heading"><span class="section-number">02</span><div><h3 id="telegram-title" data-i18n="telegram.title">Telegram and access auth</h3><p data-i18n="telegram.desc">Only chat IDs in the allowlist may trigger bot operations.</p></div></div>
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<div class="field"><label for="telegram-bot-token">Telegram Bot Token <span class="required-mark">必填</span></label><input id="telegram-bot-token" name="bot_token" type="password" autocomplete="new-password" spellcheck="false" required placeholder="每次完整保存时重新输入" /></div>
|
<div class="field"><label for="telegram-bot-token">Telegram Bot Token <span class="required-mark" data-i18n="common.required">Required</span></label><input id="telegram-bot-token" name="bot_token" type="password" autocomplete="new-password" spellcheck="false" required data-i18n-placeholder="secret.reenter" placeholder="Re-enter on each full save" /></div>
|
||||||
<div class="field"><label for="telegram-chat-id">目标 Chat ID <span class="required-mark">必填</span></label><input id="telegram-chat-id" name="telegram_chat_id" type="text" inputmode="numeric" autocomplete="off" required placeholder="例如 -1001234567890" /><small>接受完整有符号 64 位整数。</small></div>
|
<div class="field"><label for="telegram-chat-id"><span data-i18n="telegram.chatLabel">Target Chat ID</span> <span class="required-mark" data-i18n="common.required">Required</span></label><input id="telegram-chat-id" name="telegram_chat_id" type="text" inputmode="numeric" autocomplete="off" required data-i18n-placeholder="telegram.chatPh" placeholder="e.g. -1001234567890" /><small data-i18n="telegram.chatHint">Accepts a full signed 64-bit integer.</small></div>
|
||||||
<div class="field field-wide"><label for="chat-allowlist">Chat allowlist <span class="required-mark">至少一项</span></label><textarea id="chat-allowlist" name="chat_allowlist" rows="3" required spellcheck="false" placeholder="每行或逗号分隔一个 chat ID"></textarea><small>整数列表;保存前会检查格式与重复项。</small></div>
|
<div class="field field-wide"><label for="chat-allowlist">Chat allowlist <span class="required-mark" data-i18n="common.atLeastOne">At least one</span></label><textarea id="chat-allowlist" name="chat_allowlist" rows="3" required spellcheck="false" data-i18n-placeholder="telegram.allowPh" placeholder="One chat ID per line or comma-separated"></textarea><small data-i18n="telegram.allowHint">Integer list; format and duplicates are checked before saving.</small></div>
|
||||||
<div class="field"><label for="telegram-webhook-secret">Telegram Webhook Secret <span class="required-mark">必填</span></label><input id="telegram-webhook-secret" name="telegram_webhook_secret" type="password" autocomplete="new-password" spellcheck="false" required placeholder="每次完整保存时重新输入" /></div>
|
<div class="field"><label for="telegram-webhook-secret">Telegram Webhook Secret <span class="required-mark" data-i18n="common.required">Required</span></label><input id="telegram-webhook-secret" name="telegram_webhook_secret" type="password" autocomplete="new-password" spellcheck="false" required data-i18n-placeholder="secret.reenter" placeholder="Re-enter on each full save" /></div>
|
||||||
<div class="field"><label for="reconcile-token">Reconcile Bearer Token <span class="required-mark">必填</span></label><input id="reconcile-token" name="reconcile_token" type="password" autocomplete="new-password" spellcheck="false" required placeholder="每次完整保存时重新输入" /></div>
|
<div class="field"><label for="reconcile-token">Reconcile Bearer Token <span class="required-mark" data-i18n="common.required">Required</span></label><input id="reconcile-token" name="reconcile_token" type="password" autocomplete="new-password" spellcheck="false" required data-i18n-placeholder="secret.reenter" placeholder="Re-enter on each full save" /></div>
|
||||||
<div class="field field-wide"><label for="worker-token">Worker Token <span class="required-mark">必填</span></label><input id="worker-token" name="worker_token" type="password" autocomplete="new-password" spellcheck="false" required placeholder="/worker 与兼容管理 API 使用;每次完整保存时重新输入" /><small>此值与当前管理会话不同。页面不会读取或回显已有 token。</small></div>
|
<div class="field field-wide"><label for="worker-token">Worker Token <span class="required-mark" data-i18n="common.required">Required</span></label><input id="worker-token" name="worker_token" type="password" autocomplete="new-password" spellcheck="false" required data-i18n-placeholder="telegram.workerPh" placeholder="Used by /worker and compatible admin APIs; re-enter on each full save" /><small data-i18n="telegram.workerHint">This value differs from the current admin session. The page never reads or echoes an existing token.</small></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="form-section" aria-labelledby="llm-title">
|
<section class="form-section" aria-labelledby="llm-title">
|
||||||
<div class="form-section-heading"><span class="section-number">03</span><div><h3 id="llm-title">LLM 服务</h3><p>启用后要求 HTTPS Base URL、模型名和 API key。</p></div></div>
|
<div class="form-section-heading"><span class="section-number">03</span><div><h3 id="llm-title" data-i18n="llm.title">LLM service</h3><p data-i18n="llm.desc">When enabled, an HTTPS Base URL, model name and API key are required.</p></div></div>
|
||||||
<div class="toggle-grid">
|
<div class="toggle-grid">
|
||||||
<label class="toggle-card" for="llm-enabled"><input id="llm-enabled" name="llm_enabled" type="checkbox" /><span><strong>启用 LLM</strong><small>允许机器人调用兼容 OpenAI 的模型服务。</small></span></label>
|
<label class="toggle-card" for="llm-enabled"><input id="llm-enabled" name="llm_enabled" type="checkbox" /><span><strong data-i18n="llm.enableLabel">Enable LLM</strong><small data-i18n="llm.enableHint">Allow the bot to call OpenAI-compatible model services.</small></span></label>
|
||||||
<label class="toggle-card" for="llm-allow-net"><input id="llm-allow-net" name="llm_allow_net" type="checkbox" /><span><strong>允许网络调用</strong><small>关闭时不会创建 LLM 客户端。</small></span></label>
|
<label class="toggle-card" for="llm-allow-net"><input id="llm-allow-net" name="llm_allow_net" type="checkbox" /><span><strong data-i18n="llm.netLabel">Allow network calls</strong><small data-i18n="llm.netHint">When off, no LLM client is created.</small></span></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-grid llm-fields">
|
<div class="field-grid llm-fields">
|
||||||
<div class="field"><label for="llm-base-url">LLM Base URL <span id="llm-url-required" class="optional-mark">可选</span></label><input id="llm-base-url" name="llm_base_url" type="url" inputmode="url" autocomplete="url" placeholder="https://api.example.com/v1" /><small>启用 LLM 时必须使用 HTTPS。</small></div>
|
<div class="field"><label for="llm-base-url">LLM Base URL <span id="llm-url-required" class="optional-mark" data-i18n="common.optional">Optional</span></label><input id="llm-base-url" name="llm_base_url" type="url" inputmode="url" autocomplete="url" placeholder="https://api.example.com/v1" /><small data-i18n="llm.urlHint">HTTPS is required when LLM is enabled.</small></div>
|
||||||
<div class="field"><label for="llm-model">模型名称 <span id="llm-model-required" class="optional-mark">可选</span></label><input id="llm-model" name="llm_model" type="text" autocomplete="off" placeholder="留空表示 null" /></div>
|
<div class="field"><label for="llm-model"><span data-i18n="llm.modelLabel">Model name</span> <span id="llm-model-required" class="optional-mark" data-i18n="common.optional">Optional</span></label><input id="llm-model" name="llm_model" type="text" autocomplete="off" data-i18n-placeholder="llm.modelPh" placeholder="Leave blank for null" /></div>
|
||||||
<div class="field field-wide"><label for="llm-api-key">LLM API Key <span id="llm-key-required" class="optional-mark">可选</span></label><input id="llm-api-key" name="llm_api_key" type="password" autocomplete="new-password" spellcheck="false" placeholder="启用 LLM 时必填;不会回显" /></div>
|
<div class="field field-wide"><label for="llm-api-key">LLM API Key <span id="llm-key-required" class="optional-mark" data-i18n="common.optional">Optional</span></label><input id="llm-api-key" name="llm_api_key" type="password" autocomplete="new-password" spellcheck="false" data-i18n-placeholder="llm.keyPh" placeholder="Required when LLM is enabled; never echoed" /></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div class="form-footer">
|
<div class="form-footer">
|
||||||
<p id="business-save-state" class="save-state" aria-live="polite">完成管理会话验证后可填写完整配置。</p>
|
<p id="business-save-state" class="save-state" aria-live="polite" data-i18n="business.saveStateInitial">You can fill in the full configuration after the admin session is verified.</p>
|
||||||
<button id="business-save-button" class="button button-primary" type="submit" disabled><span class="button-label">保存业务配置并热加载</span><span class="button-spinner" aria-hidden="true"></span></button>
|
<button id="business-save-button" class="button button-primary" type="submit" disabled><span class="button-label" data-i18n="business.save">Save business config and hot-reload</span><span class="button-spinner" aria-hidden="true"></span></button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel config-section runtime-section" aria-labelledby="runtime-title">
|
<section class="panel config-section runtime-section" aria-labelledby="runtime-title">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div><p class="eyebrow">出站请求</p><h2 id="runtime-title">超时与重试</h2></div>
|
<div><p class="eyebrow" data-i18n="runtime.eyebrow">Outbound requests</p><h2 id="runtime-title" data-i18n="runtime.title">Timeouts and retries</h2></div>
|
||||||
<span class="persist-label"><span class="persist-dot"></span>Redis 保存</span>
|
<span class="persist-label"><span class="persist-dot"></span><span data-i18n="runtime.persist">Stored in Redis</span></span>
|
||||||
</div>
|
</div>
|
||||||
<p class="section-copy">运行参数由 <code>GET /api/config</code> 读取、由 <code>PUT /api/config</code> 更新;新值保存后立即用于后续请求。</p>
|
<p class="section-copy"><span data-i18n="runtime.bodyPre">Runtime parameters are read by </span><code>GET /api/config</code><span data-i18n="runtime.bodyMid"> and updated by </span><code>PUT /api/config</code><span data-i18n="runtime.bodyPost">; new values apply immediately to subsequent requests after saving.</span></p>
|
||||||
<form id="runtime-form" novalidate>
|
<form id="runtime-form" novalidate>
|
||||||
<fieldset id="runtime-fields" disabled>
|
<fieldset id="runtime-fields" disabled>
|
||||||
<legend class="visually-hidden">运行参数</legend>
|
<legend class="visually-hidden" data-i18n="legend.runtime">Runtime parameters</legend>
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<article class="setting-card"><label for="jmap-timeout">JMAP 超时</label><p class="setting-description">单次 JMAP 请求的等待上限。</p><div class="number-control"><input id="jmap-timeout" name="jmap_timeout_ms" type="number" min="100" max="300000" step="1" required /><span>毫秒</span></div><small>100–300,000;默认 15,000。</small></article>
|
<article class="setting-card"><label for="jmap-timeout" data-i18n="runtime.jmapLabel">JMAP timeout</label><p class="setting-description" data-i18n="runtime.jmapDesc">Upper wait limit for a single JMAP request.</p><div class="number-control"><input id="jmap-timeout" name="jmap_timeout_ms" type="number" min="100" max="300000" step="1" required /><span data-i18n="common.ms">ms</span></div><small data-i18n="runtime.jmapHint">100–300,000; default 15,000.</small></article>
|
||||||
<article class="setting-card"><label for="telegram-timeout">Telegram 超时</label><p class="setting-description">单次 Telegram 请求的等待上限。</p><div class="number-control"><input id="telegram-timeout" name="telegram_timeout_ms" type="number" min="100" max="300000" step="1" required /><span>毫秒</span></div><small>100–300,000;默认 10,000。</small></article>
|
<article class="setting-card"><label for="telegram-timeout" data-i18n="runtime.tgLabel">Telegram timeout</label><p class="setting-description" data-i18n="runtime.tgDesc">Upper wait limit for a single Telegram request.</p><div class="number-control"><input id="telegram-timeout" name="telegram_timeout_ms" type="number" min="100" max="300000" step="1" required /><span data-i18n="common.ms">ms</span></div><small data-i18n="runtime.tgHint">100–300,000; default 10,000.</small></article>
|
||||||
<article class="setting-card"><label for="llm-timeout">LLM 超时</label><p class="setting-description">单次模型请求的等待上限。</p><div class="number-control"><input id="llm-timeout" name="llm_timeout_ms" type="number" min="100" max="300000" step="1" required /><span>毫秒</span></div><small>100–300,000;默认 30,000。</small></article>
|
<article class="setting-card"><label for="llm-timeout" data-i18n="runtime.llmLabel">LLM timeout</label><p class="setting-description" data-i18n="runtime.llmDesc">Upper wait limit for a single model request.</p><div class="number-control"><input id="llm-timeout" name="llm_timeout_ms" type="number" min="100" max="300000" step="1" required /><span data-i18n="common.ms">ms</span></div><small data-i18n="runtime.llmHint">100–300,000; default 30,000.</small></article>
|
||||||
<article class="setting-card"><label for="max-retries">最大重试次数</label><p class="setting-description">失败后额外尝试的次数,0 表示不重试。</p><div class="number-control"><input id="max-retries" name="max_retries" type="number" min="0" max="5" step="1" required /><span>次</span></div><small>0–5;默认 3。</small></article>
|
<article class="setting-card"><label for="max-retries" data-i18n="runtime.retriesLabel">Max retries</label><p class="setting-description" data-i18n="runtime.retriesDesc">Extra attempts after a failure; 0 means no retry.</p><div class="number-control"><input id="max-retries" name="max_retries" type="number" min="0" max="5" step="1" required /><span data-i18n="common.times">times</span></div><small data-i18n="runtime.retriesHint">0–5; default 3.</small></article>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div class="form-footer">
|
<div class="form-footer">
|
||||||
<p id="runtime-save-state" class="save-state" aria-live="polite">读取成功后可编辑运行参数。</p>
|
<p id="runtime-save-state" class="save-state" aria-live="polite" data-i18n="runtime.saveStateInitial">Runtime parameters become editable after a successful read.</p>
|
||||||
<div class="form-actions"><button id="defaults-button" class="button button-quiet" type="button" disabled>恢复默认值</button><button id="runtime-save-button" class="button button-primary" type="submit" disabled><span class="button-label">保存运行参数</span><span class="button-spinner" aria-hidden="true"></span></button></div>
|
<div class="form-actions"><button id="defaults-button" class="button button-quiet" type="button" disabled data-i18n="runtime.defaults">Restore defaults</button><button id="runtime-save-button" class="button button-primary" type="submit" disabled><span class="button-label" data-i18n="runtime.save">Save runtime parameters</span><span class="button-spinner" aria-hidden="true"></span></button></div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside id="privacy-note" class="privacy-note" hidden><span class="privacy-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3 5 6v5c0 4.5 2.8 8 7 10 4.2-2 7-5.5 7-10V6l-7-3Z"/><path d="m9 12 2 2 4-4"/></svg></span><p><strong>密钥不会回显或持久化到浏览器。</strong>业务配置只提交至同源 API,并由后端加密写入 Redis;本页面不读取邮箱内容、不使用本地文件或数据库。</p></aside>
|
<aside id="privacy-note" class="privacy-note" hidden><span class="privacy-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3 5 6v5c0 4.5 2.8 8 7 10 4.2-2 7-5.5 7-10V6l-7-3Z"/><path d="m9 12 2 2 4-4"/></svg></span><p><strong data-i18n="privacy.title">Secrets are never echoed or persisted to the browser.</strong> <span data-i18n="privacy.body">Business configuration is submitted only to the same-origin API and encrypted into Redis by the backend; this page never reads mailbox contents and uses no local files or database.</span></p></aside>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="footer"><span>MessageWeave</span><span>配置由你的服务端管理</span></footer>
|
<footer class="footer"><span>MessageWeave</span><span data-i18n="footer.note">Configuration managed by your server</span></footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue