backfillData() only refreshed system settings after a successful backfill, leaving state.sentimentHistory/sentimentHistoryKey populated with the stale pre-backfill cache, so an already-open sentiment cycle page kept showing the old one-day history. After a successful backfill, clear both cache fields and, when the sentiment cycle view is active, force loadSentimentHistory(true) so the history renders immediately. Add a frontend contract test covering the refresh path. Co-authored-by: multica-agent <github@multica.ai>
262 lines
14 KiB
JavaScript
262 lines
14 KiB
JavaScript
async function backfillData() {
|
|
const button = document.querySelector("#backfillButton");
|
|
button.disabled = true;
|
|
setLoading(true, "正在回补历史交易日");
|
|
try {
|
|
const payload = await apiRequest("/api/backfill", "POST", {
|
|
start_date: document.querySelector("#backfillStart").value,
|
|
end_date: document.querySelector("#backfillEnd").value,
|
|
});
|
|
showToast(`历史回补完成,共处理 ${payload.results.length} 个工作日`);
|
|
state.sentimentHistory = null;
|
|
state.sentimentHistoryKey = "";
|
|
if (state.activeView === "sentimentCycleView") {
|
|
await loadSentimentHistory(true);
|
|
}
|
|
await openAdminSettings(true);
|
|
} catch (error) {
|
|
showToast(error.message);
|
|
} finally {
|
|
setLoading(false);
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
|
|
|
|
async function openAdminSettings(refreshOnly = false) {
|
|
if (state.user?.role !== "admin") return;
|
|
if (!refreshOnly) openModalDialog(elements.adminDialog);
|
|
const status = document.querySelector("#adminConnectionStatus");
|
|
status.textContent = "正在读取系统状态";
|
|
try {
|
|
const payload = await apiRequest("/api/admin/settings");
|
|
const data = payload.data || {};
|
|
const ifind = data.ifind || {};
|
|
const llm = payload.llm || {};
|
|
const membership = payload.membership || {};
|
|
status.textContent = `Tushare ${data.configured ? "已配置" : "未配置"} · iFinD ${ifind.configured ? "已配置" : "未配置"} · ${number(data.snapshot_dates)} 个交易日`;
|
|
status.classList.toggle("connected", Boolean(data.configured));
|
|
setText("systemDataStatus", data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停");
|
|
document.querySelector("#systemTokenInput").value = "";
|
|
document.querySelector("#systemIfindTokenInput").value = "";
|
|
document.querySelector("#systemBackgroundRefresh").checked = Boolean(data.background_refresh_enabled);
|
|
document.querySelector("#memberDailyLimit").value = number(membership.member_daily_limit) || 50;
|
|
renderModelPool(llm.models || [], llm.primary_model_id || "", llm.fallback_model_id || "");
|
|
renderAdminUsers(payload.users || []);
|
|
} catch (error) {
|
|
status.textContent = error.message || "系统配置读取失败";
|
|
}
|
|
}
|
|
|
|
function selectAdminPanel(panel) {
|
|
const selected = ["market", "models", "members"].includes(panel) ? panel : "market";
|
|
document.querySelector("#adminSectionSelect").value = selected;
|
|
document.querySelectorAll("[data-admin-panel]").forEach((item) => {
|
|
item.hidden = item.dataset.adminPanel !== selected;
|
|
});
|
|
}
|
|
|
|
function renderModelPool(models, primaryId = "", fallbackId = "") {
|
|
state.adminModels = models.map((item) => ({ ...item, api_key: item.api_key || "" }));
|
|
const container = document.querySelector("#modelPoolList");
|
|
container.innerHTML = state.adminModels.map((item, index) => `
|
|
<article class="model-pool-row" data-model-id="${escapeHtml(item.id)}">
|
|
<div class="model-pool-heading"><strong>${escapeHtml(item.name || `模型 ${index + 1}`)}</strong><span>${item.configured ? "已保存密钥" : "待配置"}</span></div>
|
|
<div class="model-pool-fields">
|
|
<label class="form-field"><span>显示名称 *</span><input data-model-field="name" maxlength="50" value="${escapeHtml(item.name || "")}" required></label>
|
|
<label class="form-field"><span>API Base URL *</span><input data-model-field="base_url" type="url" value="${escapeHtml(item.base_url || "https://api.openai.com/v1")}" required></label>
|
|
<label class="form-field"><span>模型标识 *</span><input data-model-field="model" maxlength="100" value="${escapeHtml(item.model || "")}" required></label>
|
|
<label class="form-field"><span>API Key${item.configured ? "" : " *"}</span><input data-model-field="api_key" type="password" autocomplete="off" maxlength="300" placeholder="${item.configured ? "留空保留已保存的 Key" : "输入 API Key"}" ${item.configured ? "" : "required"}></label>
|
|
</div>
|
|
<div class="model-test-row"><button class="button" type="button" data-test-model>测试连接</button><span class="model-test-status" aria-live="polite">未测试</span><button class="icon-button model-delete-button" type="button" data-delete-model aria-label="删除模型" title="删除模型"><i data-lucide="trash-2"></i></button></div>
|
|
</article>
|
|
`).join("") || emptyStateHtml("模型池为空,请先添加模型");
|
|
updateModelRoleOptions(primaryId, fallbackId);
|
|
container.querySelectorAll("[data-test-model]").forEach((button) => button.addEventListener("click", () => testPlatformModel(button.closest("[data-model-id]"))));
|
|
container.querySelectorAll("[data-delete-model]").forEach((button) => button.addEventListener("click", () => deletePlatformModel(button.closest("[data-model-id]"))));
|
|
container.querySelectorAll("[data-model-field='name']").forEach((input) => input.addEventListener("input", updateModelRoleLabels));
|
|
refreshIcons();
|
|
}
|
|
|
|
function collectModelPool() {
|
|
const saved = new Map(state.adminModels.map((item) => [item.id, item]));
|
|
return [...document.querySelectorAll("#modelPoolList [data-model-id]")].map((row) => ({
|
|
id: row.dataset.modelId,
|
|
name: row.querySelector("[data-model-field='name']").value.trim(),
|
|
base_url: row.querySelector("[data-model-field='base_url']").value.trim(),
|
|
model: row.querySelector("[data-model-field='model']").value.trim(),
|
|
api_key: row.querySelector("[data-model-field='api_key']").value.trim(),
|
|
configured: Boolean(saved.get(row.dataset.modelId)?.configured),
|
|
}));
|
|
}
|
|
|
|
function updateModelRoleOptions(primaryId = document.querySelector("#platformPrimaryModelSelect").value, fallbackId = document.querySelector("#platformFallbackModelSelect").value) {
|
|
const models = collectModelPool();
|
|
const options = models.map((item) => `<option value="${escapeHtml(item.id)}">${escapeHtml(item.name || item.model || "未命名模型")}</option>`).join("");
|
|
const primary = document.querySelector("#platformPrimaryModelSelect");
|
|
const fallback = document.querySelector("#platformFallbackModelSelect");
|
|
primary.innerHTML = models.length ? options : '<option value="">暂无模型</option>';
|
|
fallback.innerHTML = `<option value="">不启用辅助模型</option>${options}`;
|
|
primary.value = models.some((item) => item.id === primaryId) ? primaryId : models[0]?.id || "";
|
|
fallback.value = models.some((item) => item.id === fallbackId) && fallbackId !== primary.value ? fallbackId : "";
|
|
}
|
|
|
|
function updateModelRoleLabels() {
|
|
updateModelRoleOptions();
|
|
}
|
|
|
|
function addPlatformModel() {
|
|
const models = collectModelPool();
|
|
const id = `model-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
|
models.push({ id, name: `模型 ${models.length + 1}`, base_url: "https://api.openai.com/v1", model: "", api_key: "", configured: false });
|
|
renderModelPool(models, document.querySelector("#platformPrimaryModelSelect").value || id, document.querySelector("#platformFallbackModelSelect").value);
|
|
document.querySelector(`[data-model-id="${CSS.escape(id)}"] [data-model-field="name"]`)?.focus();
|
|
}
|
|
|
|
function deletePlatformModel(row) {
|
|
if (!row) return;
|
|
const id = row.dataset.modelId;
|
|
const primary = document.querySelector("#platformPrimaryModelSelect").value;
|
|
const fallback = document.querySelector("#platformFallbackModelSelect").value;
|
|
if (id === primary || id === fallback) {
|
|
showToast("请先为主模型或辅助模型选择其他模型,再删除当前模型");
|
|
return;
|
|
}
|
|
const models = collectModelPool().filter((item) => item.id !== id);
|
|
renderModelPool(models, primary, fallback);
|
|
}
|
|
|
|
function renderAdminUsers(users) {
|
|
const container = document.querySelector("#adminUsersList");
|
|
container.innerHTML = users.map((user) => {
|
|
const admin = user.role === "admin";
|
|
const member = Boolean(user.membership_subscribed);
|
|
const identityLabels = [admin ? "管理员" : "", member ? "会员有效" : "普通用户"].filter(Boolean).join(" · ");
|
|
const expiry = member
|
|
? (user.membership_expires_at ? `有效至 ${membershipDateDisplay(user.membership_expires_at)}` : "永久有效")
|
|
: user.membership_status === "suspended"
|
|
? "会员已停用"
|
|
: user.membership_status === "active" && user.membership_expires_at
|
|
? `已于 ${membershipDateDisplay(user.membership_expires_at)} 到期`
|
|
: "尚未开通";
|
|
return `<article class="admin-user-row" data-admin-user="${number(user.id)}">
|
|
<div class="admin-user-identity"><strong>${escapeHtml(user.username)}</strong><span>${escapeHtml(identityLabels)}</span><small>${escapeHtml(expiry)}</small></div>
|
|
<div class="admin-user-usage">今日调用 <b>${number(user.used_today)}</b></div>
|
|
<form class="membership-form">
|
|
<input type="hidden" name="user_id" value="${number(user.id)}">
|
|
<label><span>状态</span><select name="status"><option value="inactive" ${user.membership_status === "inactive" ? "selected" : ""}>未开通</option><option value="active" ${user.membership_status === "active" ? "selected" : ""}>有效</option><option value="suspended" ${user.membership_status === "suspended" ? "selected" : ""}>停用</option></select></label>
|
|
<label><span>开通 / 续期时长</span><select name="duration"><option value="">选择时长</option><option value="1_month">1个月</option><option value="3_months">3个月</option><option value="12_months">12个月</option><option value="3_years">3年</option><option value="permanent">永久</option></select></label>
|
|
<div class="membership-expiry"><span>当前到期</span><strong>${escapeHtml(expiry)}</strong></div>
|
|
<button class="button" type="submit">应用</button>
|
|
</form>
|
|
</article>`;
|
|
}).join("") || emptyStateHtml("暂无注册用户");
|
|
container.querySelectorAll(".membership-form").forEach((form) => form.addEventListener("submit", saveMembership));
|
|
}
|
|
|
|
async function saveMembership(event) {
|
|
event.preventDefault();
|
|
const form = event.currentTarget;
|
|
const data = Object.fromEntries(new FormData(form).entries());
|
|
const button = form.querySelector("button[type='submit']");
|
|
button.disabled = true;
|
|
try {
|
|
const payload = await apiRequest("/api/admin/membership", "POST", data);
|
|
renderAdminUsers(payload.users || []);
|
|
showToast("会员状态已更新");
|
|
} catch (error) {
|
|
showToast(error.message || "会员状态保存失败");
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function saveMarketSettings(event) {
|
|
event.preventDefault();
|
|
const button = event.currentTarget.querySelector("button[type='submit']");
|
|
button.disabled = true;
|
|
try {
|
|
await apiRequest("/api/admin/settings", "POST", {
|
|
tushare_token: document.querySelector("#systemTokenInput").value.trim(),
|
|
ifind_refresh_token: document.querySelector("#systemIfindTokenInput").value.trim(),
|
|
background_refresh_enabled: document.querySelector("#systemBackgroundRefresh").checked,
|
|
});
|
|
document.querySelector("#systemTokenInput").value = "";
|
|
document.querySelector("#systemIfindTokenInput").value = "";
|
|
showToast("行情配置已保存");
|
|
await openAdminSettings(true);
|
|
} catch (error) {
|
|
showToast(error.message || "系统配置保存失败");
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function saveModelPool(event) {
|
|
event.preventDefault();
|
|
const button = event.currentTarget.querySelector("button[type='submit']");
|
|
button.disabled = true;
|
|
try {
|
|
await apiRequest("/api/admin/settings", "POST", {
|
|
models: collectModelPool(),
|
|
primary_model_id: document.querySelector("#platformPrimaryModelSelect").value,
|
|
fallback_model_id: document.querySelector("#platformFallbackModelSelect").value,
|
|
});
|
|
showToast("模型池已保存");
|
|
await openAdminSettings(true);
|
|
} catch (error) {
|
|
showToast(error.message || "模型池保存失败");
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function saveMembershipSettings(event) {
|
|
event.preventDefault();
|
|
const button = event.currentTarget.querySelector("button[type='submit']");
|
|
button.disabled = true;
|
|
try {
|
|
await apiRequest("/api/admin/settings", "POST", {
|
|
member_daily_limit: number(document.querySelector("#memberDailyLimit").value),
|
|
});
|
|
showToast("会员调用额度已保存");
|
|
await openAdminSettings(true);
|
|
} catch (error) {
|
|
showToast(error.message || "会员调用额度保存失败");
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function testPlatformModel(row) {
|
|
if (!row) return;
|
|
const button = row.querySelector("[data-test-model]");
|
|
const status = row.querySelector(".model-test-status");
|
|
const profile = collectModelPool().find((item) => item.id === row.dataset.modelId) || {};
|
|
button.disabled = true;
|
|
status.textContent = "连接中";
|
|
try {
|
|
const payload = await apiRequest("/api/admin/settings/test", "POST", { model_id: row.dataset.modelId, profile });
|
|
status.textContent = `已连通 · ${number(payload.result.latency_ms)} ms`;
|
|
status.className = "model-test-status success";
|
|
} catch (error) {
|
|
status.textContent = error.message;
|
|
status.className = "model-test-status failure";
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
|
|
|
|
function bindAdminEvents() {
|
|
document.querySelector("#settingsButton").addEventListener("click", () => openAdminSettings());
|
|
document.querySelector("#closeAdminDialog").addEventListener("click", () => elements.adminDialog.close());
|
|
document.querySelector("#backfillButton").addEventListener("click", backfillData);
|
|
document.querySelector("#adminSectionSelect").addEventListener("change", (event) => selectAdminPanel(event.target.value));
|
|
document.querySelector("#systemMarketForm").addEventListener("submit", saveMarketSettings);
|
|
document.querySelector("#systemModelsForm").addEventListener("submit", saveModelPool);
|
|
document.querySelector("#membershipSettingsForm").addEventListener("submit", saveMembershipSettings);
|
|
document.querySelector("#addPlatformModel").addEventListener("click", addPlatformModel);
|
|
document.querySelector("#adminRefreshButton").addEventListener("click", startAdminRefresh);
|
|
}
|