diff --git a/data/runtime-config.json b/data/runtime-config.json
new file mode 100644
index 0000000..b58ccac
--- /dev/null
+++ b/data/runtime-config.json
@@ -0,0 +1,4 @@
+{
+ "PRICE_TARGETS": "BTC:ABOVE:95000,GLD:BELOW:420",
+ "ETF_SYMBOLS": "GLD,SLV,BNO,SMH"
+}
\ No newline at end of file
diff --git a/src/bot/commandHandlers.js b/src/bot/commandHandlers.js
index cb7d175..be8f785 100644
--- a/src/bot/commandHandlers.js
+++ b/src/bot/commandHandlers.js
@@ -3,6 +3,12 @@ const { sendTelegramMessage } = require("../telegram");
const { fetchYahooQuotes } = require("../providers/yahoo");
const { formatPrice, formatPercent, escapeHtml } = require("../utils/formatters");
const { formatRecentEvents } = require("../utils/eventHistory");
+const {
+ getEditableSettings,
+ getEditableSettingKeys,
+ setEditableSetting,
+ resetEditableSetting,
+} = require("../runtimeConfig");
async function handleCommand(text, state, handlers) {
const parts = text.trim().split(/\s+/);
@@ -113,6 +119,82 @@ async function handleCommand(text, state, handlers) {
return;
}
+ if (command === "/config") {
+ const settings = getEditableSettings();
+ const top = settings
+ .slice(0, 14)
+ .map(
+ (item) =>
+ `• ${escapeHtml(item.key)} = ${escapeHtml(item.value || "")}${
+ item.overridden ? " (runtime)" : ""
+ }`
+ )
+ .join("\n");
+ await sendTelegramMessage(
+ `⚙️ Runtime Config\n\n` +
+ `${top}\n\n` +
+ `Usa /set KEY VALUE para cambiar y /unset KEY para restaurar desde .env.`,
+ { parseMode: "HTML" }
+ );
+ return;
+ }
+
+ if (command === "/set") {
+ const match = text.match(/^\/set(?:@\w+)?\s+(\S+)\s+([\s\S]+)$/i);
+ if (!match) {
+ await sendTelegramMessage(
+ `Uso: /set KEY VALUE\nEjemplo: /set ETF_WARNING_THRESHOLD_PERCENT 0.8`,
+ { parseMode: "HTML" }
+ );
+ return;
+ }
+
+ const envKey = String(match[1] || "").toUpperCase();
+ const rawValue = String(match[2] || "").trim();
+
+ try {
+ const updated = await setEditableSetting(envKey, rawValue);
+ await sendTelegramMessage(
+ `✅ ${escapeHtml(updated.key)} actualizado a ${escapeHtml(
+ updated.value
+ )}\nCambio aplicado en runtime y persistido.`,
+ { parseMode: "HTML" }
+ );
+ } catch (error) {
+ const validKeys = getEditableSettingKeys().slice(0, 18).join(", ");
+ await sendTelegramMessage(
+ `⚠️ No se pudo actualizar: ${escapeHtml(error.message)}\n\n` +
+ `Claves soportadas (resumen):\n${escapeHtml(validKeys)}`,
+ { parseMode: "HTML" }
+ );
+ }
+ return;
+ }
+
+ if (command === "/unset") {
+ if (!arg) {
+ await sendTelegramMessage(
+ `Uso: /unset KEY\nEjemplo: /unset ETF_WARNING_THRESHOLD_PERCENT`,
+ { parseMode: "HTML" }
+ );
+ return;
+ }
+
+ const envKey = String(arg || "").toUpperCase();
+ try {
+ const updated = await resetEditableSetting(envKey);
+ await sendTelegramMessage(
+ `↩️ ${escapeHtml(updated.key)} restaurado a ${escapeHtml(
+ updated.value
+ )} desde .env`,
+ { parseMode: "HTML" }
+ );
+ } catch (error) {
+ await sendTelegramMessage(`⚠️ ${escapeHtml(error.message)}`, { parseMode: "HTML" });
+ }
+ return;
+ }
+
if (command === "/ayuda" || command === "/help") {
await sendTelegramMessage(
`🤖 Market Watcher — Commands\n\n` +
@@ -120,6 +202,9 @@ async function handleCommand(text, state, handlers) {
`/price SYMBOL — Current price for an asset\n` +
`/status — Bot status and last check\n` +
`/events — Recent alert and schedule history\n` +
+ `/config — Show current runtime config\n` +
+ `/set KEY VALUE — Update runtime config\n` +
+ `/unset KEY — Restore value from .env\n` +
`/help — This help\n\n` +
`Examples: /price BTC /price GLD /price AAPL`,
{ parseMode: "HTML" }
diff --git a/src/config.js b/src/config.js
index e28f0ff..7d94bc4 100644
--- a/src/config.js
+++ b/src/config.js
@@ -99,7 +99,7 @@ const config = {
apiRetryMaxDelayMs: parseNumber(process.env.API_RETRY_MAX_DELAY_MS, 4000),
requestTimeoutMs: 15000,
webEnabled: (process.env.WEB_ENABLED || "true").toLowerCase() !== "false",
- webPort: parseNumber(process.env.WEB_PORT, 1903),
+ webPort: parseNumber(process.env.WEB_PORT, 1904),
maxCsvSizeMb: parseNumber(process.env.MAX_CSV_SIZE_MB, 20),
};
diff --git a/src/index.js b/src/index.js
index 73b12db..f487153 100644
--- a/src/index.js
+++ b/src/index.js
@@ -4,6 +4,7 @@ const { runCycle, buildDailyReport, fetchAllQuotes } = require("./marketMonitor"
const { sendTelegramMessage } = require("./telegram");
const { pollAndHandle } = require("./telegramCommands");
const { startWebServer } = require("./webServer");
+const { loadRuntimeConfig } = require("./runtimeConfig");
const { logger } = require("./utils/logger");
let cycleRunning = false;
@@ -47,6 +48,7 @@ async function safeRunCycle(state, options) {
}
async function main() {
+ await loadRuntimeConfig();
validateConfig();
const state = await loadState();
@@ -57,19 +59,23 @@ async function main() {
logger.info("Starting market monitor...");
await safeRunCycle(state, { isStartup: true });
- setInterval(async () => {
+ const runCycleLoop = async () => {
await safeRunCycle(state, { isStartup: false });
- }, config.checkIntervalMs);
+ setTimeout(runCycleLoop, config.checkIntervalMs);
+ };
+ setTimeout(runCycleLoop, config.checkIntervalMs);
// Telegram command polling (/report, /price, /status, /help)
const commandHandlers = { buildDailyReport, fetchAllQuotes };
- setInterval(async () => {
+ const pollLoop = async () => {
try {
await pollAndHandle(state, commandHandlers);
} catch (error) {
logger.warn(`Telegram polling error: ${error.message}`);
}
- }, config.commandPollIntervalMs);
+ setTimeout(pollLoop, config.commandPollIntervalMs);
+ };
+ setTimeout(pollLoop, config.commandPollIntervalMs);
logger.info(
`Background monitor active. Interval: ${Math.round(config.checkIntervalMs / 60000)} minutes.`
diff --git a/src/runtimeConfig.js b/src/runtimeConfig.js
new file mode 100644
index 0000000..36d032f
--- /dev/null
+++ b/src/runtimeConfig.js
@@ -0,0 +1,463 @@
+const fs = require("node:fs/promises");
+const path = require("node:path");
+const { config, validateConfig } = require("./config");
+
+const RUNTIME_CONFIG_FILE = path.join(process.cwd(), "data", "runtime-config.json");
+
+const baseConfigSnapshot = JSON.parse(JSON.stringify(config));
+let overrides = {};
+
+const editableSettings = {
+ CRYPTO_IDS: {
+ path: ["cryptoIds"],
+ type: "csv",
+ description: "Crypto IDs from CoinGecko (comma separated)",
+ },
+ ETF_SYMBOLS: {
+ path: ["etfSymbols"],
+ type: "csv",
+ description: "ETF tickers (comma separated)",
+ },
+ INDEX_FUND_SYMBOLS: {
+ path: ["indexFundSymbols"],
+ type: "csv",
+ description: "Index fund tickers (comma separated)",
+ },
+ STOCK_SYMBOLS: {
+ path: ["stockSymbols"],
+ type: "csv",
+ description: "Stock tickers (comma separated)",
+ },
+ PRICE_TARGETS: {
+ path: ["priceTargets"],
+ type: "priceTargets",
+ description: "Price targets: SYMBOL:ABOVE|BELOW:VALUE",
+ },
+ CHECK_INTERVAL_MINUTES: {
+ path: ["checkIntervalMs"],
+ type: "minutesToMs",
+ min: 1,
+ description: "Main market check interval in minutes",
+ },
+ CRYPTO_WARNING_THRESHOLD_PERCENT: {
+ path: ["thresholds", "crypto", "warning"],
+ type: "number",
+ min: 0.01,
+ description: "Crypto warning threshold (%)",
+ },
+ CRYPTO_STRONG_THRESHOLD_PERCENT: {
+ path: ["thresholds", "crypto", "strong"],
+ type: "number",
+ min: 0.01,
+ description: "Crypto strong threshold (%)",
+ },
+ CRYPTO_CRITICAL_THRESHOLD_PERCENT: {
+ path: ["thresholds", "crypto", "critical"],
+ type: "number",
+ min: 0.01,
+ description: "Crypto critical threshold (%)",
+ },
+ ETF_WARNING_THRESHOLD_PERCENT: {
+ path: ["thresholds", "etf", "warning"],
+ type: "number",
+ min: 0.01,
+ description: "ETF warning threshold (%)",
+ },
+ ETF_STRONG_THRESHOLD_PERCENT: {
+ path: ["thresholds", "etf", "strong"],
+ type: "number",
+ min: 0.01,
+ description: "ETF strong threshold (%)",
+ },
+ ETF_CRITICAL_THRESHOLD_PERCENT: {
+ path: ["thresholds", "etf", "critical"],
+ type: "number",
+ min: 0.01,
+ description: "ETF critical threshold (%)",
+ },
+ INDEX_WARNING_THRESHOLD_PERCENT: {
+ path: ["thresholds", "index", "warning"],
+ type: "number",
+ min: 0.01,
+ description: "Index warning threshold (%)",
+ },
+ INDEX_STRONG_THRESHOLD_PERCENT: {
+ path: ["thresholds", "index", "strong"],
+ type: "number",
+ min: 0.01,
+ description: "Index strong threshold (%)",
+ },
+ INDEX_CRITICAL_THRESHOLD_PERCENT: {
+ path: ["thresholds", "index", "critical"],
+ type: "number",
+ min: 0.01,
+ description: "Index critical threshold (%)",
+ },
+ STOCK_WARNING_THRESHOLD_PERCENT: {
+ path: ["thresholds", "stock", "warning"],
+ type: "number",
+ min: 0.01,
+ description: "Stock warning threshold (%)",
+ },
+ STOCK_STRONG_THRESHOLD_PERCENT: {
+ path: ["thresholds", "stock", "strong"],
+ type: "number",
+ min: 0.01,
+ description: "Stock strong threshold (%)",
+ },
+ STOCK_CRITICAL_THRESHOLD_PERCENT: {
+ path: ["thresholds", "stock", "critical"],
+ type: "number",
+ min: 0.01,
+ description: "Stock critical threshold (%)",
+ },
+ ACCUMULATED_CHANGE_WINDOW_MINUTES: {
+ path: ["accumulatedChangeWindowMs"],
+ type: "minutesToMs",
+ min: 1,
+ description: "Window for accumulated change (minutes)",
+ },
+ PRICE_HISTORY_RETENTION_HOURS: {
+ path: ["priceHistoryRetentionMs"],
+ type: "hoursToMs",
+ min: 1,
+ description: "Price history retention (hours)",
+ },
+ ALERT_COOLDOWN_MINUTES: {
+ path: ["alertCooldownMs"],
+ type: "minutesToMs",
+ min: 0,
+ description: "Cooldown between alerts for same asset",
+ },
+ MARKET_OPEN_HOUR: {
+ path: ["marketOpenHour"],
+ type: "int",
+ min: 0,
+ max: 23,
+ description: "Market open hour (local)",
+ },
+ MARKET_OPEN_MINUTE: {
+ path: ["marketOpenMinute"],
+ type: "int",
+ min: 0,
+ max: 59,
+ description: "Market open minute (local)",
+ },
+ MARKET_CLOSE_HOUR: {
+ path: ["marketCloseHour"],
+ type: "int",
+ min: 0,
+ max: 23,
+ description: "Market close hour (local)",
+ },
+ MARKET_CLOSE_MINUTE: {
+ path: ["marketCloseMinute"],
+ type: "int",
+ min: 0,
+ max: 59,
+ description: "Market close minute (local)",
+ },
+ COMMAND_POLL_INTERVAL_SECONDS: {
+ path: ["commandPollIntervalMs"],
+ type: "secondsToMs",
+ min: 1,
+ description: "Telegram command polling interval",
+ },
+ TELEGRAM_LONG_POLLING_TIMEOUT_SECONDS: {
+ path: ["telegramLongPollingTimeoutSeconds"],
+ type: "number",
+ min: 0,
+ max: 50,
+ description: "Telegram long polling timeout",
+ },
+ TELEGRAM_LONG_POLLING_GRACE_SECONDS: {
+ path: ["telegramLongPollingGraceSeconds"],
+ type: "number",
+ min: 0,
+ description: "Telegram long polling grace time",
+ },
+ TELEGRAM_UPDATES_LIMIT: {
+ path: ["telegramUpdatesLimit"],
+ type: "int",
+ min: 1,
+ description: "Telegram updates limit per poll",
+ },
+ MAX_QUOTE_AGE_MINUTES: {
+ path: ["maxQuoteAgeMinutes"],
+ type: "number",
+ min: 1,
+ description: "Max quote age for freshness checks",
+ },
+ RECENT_EVENTS_LIMIT: {
+ path: ["recentEventsLimit"],
+ type: "int",
+ min: 0,
+ description: "Max events stored in state",
+ },
+ DAILY_REPORT_RECENT_EVENTS_LIMIT: {
+ path: ["dailyReportRecentEventsLimit"],
+ type: "int",
+ min: 0,
+ description: "Events shown in daily report",
+ },
+ API_RETRY_ATTEMPTS: {
+ path: ["apiRetryAttempts"],
+ type: "int",
+ min: 1,
+ description: "API retry attempts",
+ },
+ API_RETRY_BASE_DELAY_MS: {
+ path: ["apiRetryBaseDelayMs"],
+ type: "int",
+ min: 0,
+ description: "API retry base delay (ms)",
+ },
+ API_RETRY_BACKOFF_MULTIPLIER: {
+ path: ["apiRetryBackoffMultiplier"],
+ type: "number",
+ min: 1,
+ description: "API retry backoff multiplier",
+ },
+ API_RETRY_MAX_DELAY_MS: {
+ path: ["apiRetryMaxDelayMs"],
+ type: "int",
+ min: 0,
+ description: "API retry max delay (ms)",
+ },
+ WEB_ENABLED: {
+ path: ["webEnabled"],
+ type: "bool",
+ description: "Enable web dashboard",
+ },
+ MAX_CSV_SIZE_MB: {
+ path: ["maxCsvSizeMb"],
+ type: "number",
+ min: 1,
+ description: "CSV rotation size threshold (MB)",
+ },
+};
+
+function deepClone(value) {
+ return JSON.parse(JSON.stringify(value));
+}
+
+function getByPath(obj, pathParts) {
+ return pathParts.reduce((acc, key) => (acc == null ? undefined : acc[key]), obj);
+}
+
+function setByPath(obj, pathParts, value) {
+ let current = obj;
+ for (let i = 0; i < pathParts.length - 1; i += 1) {
+ current = current[pathParts[i]];
+ }
+ current[pathParts[pathParts.length - 1]] = value;
+}
+
+function parseCsv(value) {
+ return String(value || "")
+ .split(",")
+ .map((item) => item.trim())
+ .filter(Boolean);
+}
+
+function parsePriceTargets(rawValue) {
+ const value = String(rawValue || "").trim();
+ if (!value) return [];
+
+ return value
+ .split(",")
+ .map((segment) => {
+ const parts = segment.trim().split(":");
+ if (parts.length !== 3) {
+ throw new Error(`Invalid PRICE_TARGETS entry: ${segment}`);
+ }
+ const [symbol, direction, thresholdRaw] = parts;
+ const threshold = Number(thresholdRaw);
+ const dir = direction.trim().toUpperCase();
+ if (!["ABOVE", "BELOW"].includes(dir) || !Number.isFinite(threshold)) {
+ throw new Error(`Invalid PRICE_TARGETS entry: ${segment}`);
+ }
+ return {
+ symbol: symbol.trim().toUpperCase(),
+ direction: dir,
+ threshold,
+ };
+ })
+ .filter(Boolean);
+}
+
+function parseNumberWithLimits(rawValue, schema, parseIntMode = false) {
+ const num = parseIntMode ? Number.parseInt(String(rawValue), 10) : Number(rawValue);
+ if (!Number.isFinite(num)) {
+ throw new Error(`Invalid numeric value for ${schema.envKey}`);
+ }
+ if (Number.isFinite(schema.min) && num < schema.min) {
+ throw new Error(`${schema.envKey} must be >= ${schema.min}`);
+ }
+ if (Number.isFinite(schema.max) && num > schema.max) {
+ throw new Error(`${schema.envKey} must be <= ${schema.max}`);
+ }
+ return num;
+}
+
+function parseBoolean(rawValue) {
+ const value = String(rawValue || "")
+ .trim()
+ .toLowerCase();
+ if (["1", "true", "yes", "on"].includes(value)) return true;
+ if (["0", "false", "no", "off"].includes(value)) return false;
+ throw new Error("Boolean value expected (true/false)");
+}
+
+function parseSettingValue(schema, rawValue) {
+ switch (schema.type) {
+ case "csv":
+ return parseCsv(rawValue);
+ case "priceTargets":
+ return parsePriceTargets(rawValue);
+ case "number":
+ return parseNumberWithLimits(rawValue, schema, false);
+ case "int":
+ return parseNumberWithLimits(rawValue, schema, true);
+ case "minutesToMs":
+ return parseNumberWithLimits(rawValue, schema, false) * 60 * 1000;
+ case "hoursToMs":
+ return parseNumberWithLimits(rawValue, schema, false) * 60 * 60 * 1000;
+ case "secondsToMs":
+ return parseNumberWithLimits(rawValue, schema, false) * 1000;
+ case "bool":
+ return parseBoolean(rawValue);
+ default:
+ throw new Error(`Unsupported setting type for ${schema.envKey}`);
+ }
+}
+
+function serializeSettingValue(schema, value) {
+ switch (schema.type) {
+ case "csv":
+ return Array.isArray(value) ? value.join(",") : "";
+ case "priceTargets":
+ return Array.isArray(value)
+ ? value
+ .map((entry) => `${entry.symbol}:${entry.direction}:${entry.threshold}`)
+ .join(",")
+ : "";
+ case "minutesToMs":
+ return String(Number(value) / (60 * 1000));
+ case "hoursToMs":
+ return String(Number(value) / (60 * 60 * 1000));
+ case "secondsToMs":
+ return String(Number(value) / 1000);
+ case "bool":
+ return value ? "true" : "false";
+ default:
+ return String(value);
+ }
+}
+
+async function saveOverrides() {
+ await fs.mkdir(path.dirname(RUNTIME_CONFIG_FILE), { recursive: true });
+ await fs.writeFile(RUNTIME_CONFIG_FILE, JSON.stringify(overrides, null, 2), "utf8");
+}
+
+async function loadRuntimeConfig() {
+ try {
+ const content = await fs.readFile(RUNTIME_CONFIG_FILE, "utf8");
+ const parsed = JSON.parse(content);
+ overrides = parsed && typeof parsed === "object" ? parsed : {};
+ } catch (error) {
+ if (error.code !== "ENOENT") {
+ throw error;
+ }
+ overrides = {};
+ }
+
+ for (const [envKey, rawValue] of Object.entries(overrides)) {
+ const schema = editableSettings[envKey];
+ if (!schema) continue;
+ schema.envKey = envKey;
+ try {
+ const parsed = parseSettingValue(schema, rawValue);
+ setByPath(config, schema.path, parsed);
+ } catch {
+ // Ignore invalid persisted value and keep default from .env
+ }
+ }
+
+ validateConfig();
+}
+
+function getEditableSettings() {
+ return Object.entries(editableSettings).map(([envKey, schema]) => {
+ const currentValue = getByPath(config, schema.path);
+ return {
+ key: envKey,
+ description: schema.description,
+ value: serializeSettingValue(schema, currentValue),
+ overridden: Object.prototype.hasOwnProperty.call(overrides, envKey),
+ };
+ });
+}
+
+function getEditableSettingKeys() {
+ return Object.keys(editableSettings);
+}
+
+async function setEditableSetting(envKey, rawValue) {
+ const schema = editableSettings[envKey];
+ if (!schema) {
+ throw new Error(`Unsupported setting: ${envKey}`);
+ }
+ schema.envKey = envKey;
+
+ const previousValue = deepClone(getByPath(config, schema.path));
+ const parsed = parseSettingValue(schema, rawValue);
+
+ try {
+ setByPath(config, schema.path, parsed);
+ validateConfig();
+ overrides[envKey] = serializeSettingValue(schema, parsed);
+ await saveOverrides();
+ } catch (error) {
+ setByPath(config, schema.path, previousValue);
+ throw error;
+ }
+
+ return {
+ key: envKey,
+ value: serializeSettingValue(schema, getByPath(config, schema.path)),
+ };
+}
+
+async function resetEditableSetting(envKey) {
+ const schema = editableSettings[envKey];
+ if (!schema) {
+ throw new Error(`Unsupported setting: ${envKey}`);
+ }
+
+ const previousValue = deepClone(getByPath(config, schema.path));
+ const baseValue = deepClone(getByPath(baseConfigSnapshot, schema.path));
+
+ try {
+ setByPath(config, schema.path, baseValue);
+ validateConfig();
+ delete overrides[envKey];
+ await saveOverrides();
+ } catch (error) {
+ setByPath(config, schema.path, previousValue);
+ throw error;
+ }
+
+ return {
+ key: envKey,
+ value: serializeSettingValue(schema, getByPath(config, schema.path)),
+ };
+}
+
+module.exports = {
+ loadRuntimeConfig,
+ getEditableSettings,
+ getEditableSettingKeys,
+ setEditableSetting,
+ resetEditableSetting,
+};
diff --git a/src/webServer.js b/src/webServer.js
index cd37b0e..897173f 100644
--- a/src/webServer.js
+++ b/src/webServer.js
@@ -1,6 +1,11 @@
const http = require("node:http");
const { config } = require("./config");
const { logger } = require("./utils/logger");
+const {
+ getEditableSettings,
+ setEditableSetting,
+ resetEditableSetting,
+} = require("./runtimeConfig");
function safePercent(value) {
if (!Number.isFinite(value)) return "N/D";
@@ -269,12 +274,209 @@ function htmlPage() {
font-size: 0.84rem;
text-align: right;
}
+ .config-controls {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-bottom: 10px;
+ align-items: stretch;
+ }
+ .config-controls select,
+ .config-controls input {
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 8px 10px;
+ font-size: 0.9rem;
+ background: #fff;
+ color: var(--text);
+ }
+ .btn {
+ border: 1px solid transparent;
+ border-radius: 10px;
+ padding: 8px 11px;
+ font-size: 0.86rem;
+ cursor: pointer;
+ font-weight: 650;
+ }
+ .btn-primary {
+ background: #e7f2fa;
+ border-color: #bbd8ed;
+ color: #0f4d78;
+ }
+ .btn-secondary {
+ background: #f3f5f8;
+ border-color: #d7e0e8;
+ color: #3c4f5f;
+ }
+ .config-status {
+ color: var(--muted);
+ font-size: 0.84rem;
+ margin-bottom: 8px;
+ min-height: 1.1rem;
+ }
+ .config-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 8px;
+ font-size: 0.85rem;
+ }
+ .config-item {
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 8px;
+ background: #fbfeff;
+ }
+ .config-item .key {
+ font-weight: 700;
+ font-size: 0.78rem;
+ color: #27455d;
+ margin-bottom: 4px;
+ }
+ .config-item .value {
+ font-family: Consolas, Menlo, monospace;
+ font-size: 0.78rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .runtime-flag {
+ color: #956200;
+ font-size: 0.74rem;
+ margin-top: 3px;
+ }
+ .config-help {
+ font-size: 0.82rem;
+ color: var(--muted);
+ margin-bottom: 8px;
+ }
+ .quick-picks {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 10px;
+ }
+ .quick-btn {
+ border: 1px solid #d7e0e8;
+ background: #f7fbfd;
+ color: #234258;
+ border-radius: 999px;
+ font-size: 0.78rem;
+ font-weight: 600;
+ padding: 4px 9px;
+ cursor: pointer;
+ }
+ .config-content {
+ display: grid;
+ grid-template-columns: 1fr minmax(320px, 420px);
+ gap: 14px;
+ margin-top: 10px;
+ min-width: 0;
+ width: 100%;
+ }
+ .config-left {
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 12px;
+ background: #fbfeff;
+ min-width: 0;
+ overflow: auto;
+ max-width: 100%;
+ }
+ .config-right {
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 12px;
+ background: #f9fcfe;
+ min-height: 420px;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ max-width: 420px;
+ width: 100%;
+ overflow: auto;
+ }
+ @media (max-width: 1100px) {
+ .config-content {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ }
+ .config-left, .config-right {
+ max-width: 100%;
+ min-width: 0;
+ }
+ }
+ .config-list-title {
+ font-size: 0.88rem;
+ font-weight: 700;
+ color: #27455d;
+ margin-bottom: 8px;
+ }
+ .config-list-wrap {
+ overflow: auto;
+ max-height: 56vh;
+ padding-right: 4px;
+ min-width: 0;
+ }
+ .config-modal {
+ display: none;
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ background: rgba(16, 30, 45, 0.45);
+ padding: 18px;
+ align-items: center;
+ justify-content: center;
+ }
+ .config-modal.open {
+ display: flex;
+ }
+ .config-modal-body {
+ width: min(1240px, 98vw);
+ max-height: 92vh;
+ overflow: auto;
+ }
+ .config-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 8px;
+ }
+ .config-close {
+ border: 1px solid #d7e0e8;
+ background: #fff;
+ color: #3c4f5f;
+ border-radius: 8px;
+ padding: 4px 8px;
+ font-size: 0.8rem;
+ cursor: pointer;
+ font-weight: 600;
+ }
@media (max-width: 720px) {
.wrap { padding: 14px; }
.top { align-items: flex-start; flex-direction: column; }
.chip-group { width: 100%; justify-content: flex-start; }
.card { padding: 12px; }
.footer { text-align: left; }
+ .config-controls {
+ grid-template-columns: 1fr;
+ }
+ .config-content {
+ grid-template-columns: 1fr;
+ }
+ .config-right {
+ min-height: 240px;
+ }
+ .config-list-wrap {
+ max-height: 36vh;
+ }
+ .config-modal {
+ padding: 10px;
+ }
+ .config-modal-body {
+ max-height: 94vh;
+ }
}
@@ -288,6 +490,7 @@ function htmlPage() {