Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions ui/index.html
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AITokenPool</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%234ecdc4'/%3E%3Cstop offset='1' stop-color='%232a9d8f'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='14' fill='url(%23g)'/%3E%3Ctext x='32' y='43' font-family='-apple-system,Segoe UI,Arial,sans-serif' font-size='30' font-weight='800' text-anchor='middle' fill='%23062021'%3EAT%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="css/style.css?v=20260825-1">
<link rel="stylesheet" href="css/style.css?v=20260825-2">
</head>
<body>

Expand DownExpand Up@@ -683,9 +683,9 @@ <h3 id="chat-title">使用模型</h3>
</div>
</div>

<script src="js/api.js?v=20260825-1"></script>
<script src="js/data.js?v=20260825-1"></script>
<script src="js/i18n.js?v=20260825-1"></script>
<script src="js/app.js?v=20260825-1"></script>
<script src="js/api.js?v=20260825-2"></script>
<script src="js/data.js?v=20260825-2"></script>
<script src="js/i18n.js?v=20260825-2"></script>
<script src="js/app.js?v=20260825-2"></script>
</body>
</html>
212 changes: 138 additions & 74 deletions ui/js/app.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -1606,35 +1606,9 @@
return out;
}

function buildDataTable(cfg) {
const { container, columns, rows, state, onState, serverPaging } = cfg;

// 1) 筛选
let data = filterRows(rows, columns, state.filters);

// 2) 排序(多列:Shift 点击叠加)
if (state.sort.length) {
data = data.slice().sort((a, b) => {
for (const sk of state.sort) {
const col = columns.find((c) => c.key === sk.key);
const av = a[sk.key], bv = b[sk.key];
let cmp;
if (col && col.sort === "number") cmp = Number(av) - Number(bv);
else cmp = String(av).localeCompare(String(bv), "zh-CN");
if (cmp !== 0) return sk.dir === "asc" ? cmp : -cmp;
}
return 0;
});
}

// 3) 分页(serverPaging:总数取后端 total,行即当前页——服务端已翻页,不做本地 slice)
const totalRows = serverPaging ? serverPaging.total : data.length;
const pages = Math.max(1, Math.ceil(totalRows / state.pageSize));
if (state.page > pages) state.page = pages;
const pageRows = serverPaging ? data : data.slice((state.page - 1) * state.pageSize, state.page * state.pageSize);

// 4) 渲染表头(排序按钮)+ 筛选行
let html = '<table class="table"><thead><tr>';
// 表头(排序按钮)+ 筛选行 HTML(rant 2026-08-25T11:15:16:拆出独立渲染,整表重建不销毁筛选输入框)
function tableTheadHtml(columns, state) {
let html = "<tr>";
columns.forEach((col) => {
const sk = state.sort.find((s) => s.key === col.key);
const arrow = sk ? (sk.dir === "asc" ? " ▲" : " ▼") : "";
Expand All@@ -1658,7 +1632,13 @@
html += "<td></td>";
}
});
html += "</tr></thead><tbody>";
html += "</tr>";
return html;
}

// 数据行 HTML(每次重建 tbody 内容)
function tableBodyHtml(pageRows, columns) {
let html = "";
if (!pageRows.length) html += '<tr><td colspan="' + columns.length + '" class="empty-cell">' + emptyState(T("tx.empty"), T("tx.empty.sub")) + "</td></tr>";
pageRows.forEach((row) => {
html += "<tr>";
Expand All@@ -1668,72 +1648,156 @@
});
html += "</tr>";
});
html += "</tbody></table>";
return html;
}

// 5) 分页器 + 每页行数
if (pages > 1) {
html += '<div class="pager">';
pagerButtons(state.page, pages).forEach((p) => {
if (p === "…") html += '<span class="pager-ellipsis">…</span>';
else html += '<button type="button" class="' + (p === state.page ? "active" : "") + '" data-p="' + p + '">' + p + "</button>";
function buildDataTable(cfg) {
const { container, columns, rows, state, onState, serverPaging } = cfg;

// 1) 筛选
let data = filterRows(rows, columns, state.filters);

// 2) 排序(多列:Shift 点击叠加)
if (state.sort.length) {
data = data.slice().sort((a, b) => {
for (const sk of state.sort) {
const col = columns.find((c) => c.key === sk.key);
const av = a[sk.key], bv = b[sk.key];
let cmp;
if (col && col.sort === "number") cmp = Number(av) - Number(bv);
else cmp = String(av).localeCompare(String(bv), "zh-CN");
if (cmp !== 0) return sk.dir === "asc" ? cmp : -cmp;
}
return 0;
});
html += "<span>" + state.page + " / " + pages + " · " + T("tx.pager.count", { n: totalRows }) + "</span></div>";
}
html += '<div class="pager-size">' + T("tx.pager.size") + ' <select data-page-size><option value="5">5</option><option value="10">10</option><option value="25">25</option><option value="50">50</option></select> ' + T("tx.pager.rows") + '</div>';

container.innerHTML = html;

// 6) 事件绑定
container.querySelectorAll("[data-sort-key]").forEach((b) => {
b.addEventListener("click", (e) => {
const key = b.dataset.sortKey;
const ex = state.sort.find((s) => s.key === key);
if (ex) {
if (ex.dir === "asc") ex.dir = "desc";
else state.sort = state.sort.filter((s) => s.key !== key);
} else {
if (!e.shiftKey) state.sort = [];
state.sort.push({ key, dir: "asc" });

// 3) 分页(serverPaging:总数取后端 total,行即当前页——服务端已翻页,不做本地 slice)
const totalRows = serverPaging ? serverPaging.total : data.length;
const pages = Math.max(1, Math.ceil(totalRows / state.pageSize));
if (state.page > pages) state.page = pages;
const pageRows = serverPaging ? data : data.slice((state.page - 1) * state.pageSize, state.page * state.pageSize);

// 4) 表头 + 筛选行:仅在容器无 <table> 时渲染一次(rant 2026-08-25T11:15:16:
// 整表重建改为只重建数据行 + 分页器,筛选输入框 DOM 永不销毁 → 输入焦点天然保留;
// 输入框值即 DOM 状态源,数据行重建读 state.filters,二者一致)
let table = container.querySelector("table");
if (!table) {
container.innerHTML = '<table class="table"></table>';
table = container.querySelector("table");
const thead = document.createElement("thead");
thead.innerHTML = tableTheadHtml(columns, state); // 初值取 state.filters
table.appendChild(thead);
table.appendChild(document.createElement("tbody"));
// 一次性容器级事件委托(排序 / 筛选输入 / 分页 / 每页行数 / IME 组合)——
// 重建不重绑,事件不丢失;处理器读取 container._dt(每次渲染刷新)
container.addEventListener("click", (e) => {
const dt = container._dt; if (!dt) return;
const sb = e.target.closest("[data-sort-key]");
if (sb) {
const key = sb.dataset.sortKey;
const ex = dt.state.sort.find((s) => s.key === key);
if (ex) {
if (ex.dir === "asc") ex.dir = "desc";
else dt.state.sort = dt.state.sort.filter((s) => s.key !== key);
} else {
if (!e.shiftKey) dt.state.sort = [];
dt.state.sort.push({ key, dir: "asc" });
}
dt.state.page = 1;
dt.onState();
return;
}
state.page = 1;
onState();
const pb = e.target.closest("[data-p]");
if (pb) { dt.state.page = Number(pb.dataset.p); dt.onState(); }
});
});
container.querySelectorAll(".th-filter").forEach((el) => {
el.addEventListener("input", () => {
container.addEventListener("input", (e) => {
const dt = container._dt; if (!dt) return;
const el = e.target.closest(".th-filter");
if (!el) return;
const key = el.dataset.filterKey;
const range = el.dataset.range;
if (range) {
const other = container.querySelector('[data-filter-key="' + key + '"][data-range="' + (range === "min" ? "max" : "min") + '"]');
const min = range === "min" ? el.value : (other ? other.value : "");
const max = range === "max" ? el.value : (other ? other.value : "");
state.filters[key] = min + ":" + max;
dt.state.filters[key] = min + ":" + max;
} else {
state.filters[key] = el.value;
dt.state.filters[key] = el.value;
}
state.page = 1;
// rant 2026-08-23T16:01:07 Bug 2:逐字符 input 立即 onState() 重建表格会打断输入
// (如想输入 "sh" 变成 "hs")。改为 300ms 防抖,刷新后恢复焦点并置光标到末尾。
dt.state.page = 1;
// rant 2026-08-23T16:01:07 Bug 2:逐字符 input 立即 onState() 会打断输入 → 300ms 防抖。
// rant 2026-08-25T11:15:16:筛选行不再被销毁,焦点天然保留;恢复焦点仅兜底
// (已聚焦时不动光标),IME 组合期间跳过(避免 setSelectionRange 打断中文输入法组合态)。
if (el._dbTimer) clearTimeout(el._dbTimer);
const focusSel = range ? '[data-filter-key="' + key + '"][data-range="' + range + '"]' : '[data-filter-key="' + key + '"]';
el._dbTimer = setTimeout(() => {
onState();
if (el._composing) return; // 组合中:不重建表格、不碰光标,待 compositionend 后再刷新
dt.onState();
const n = container.querySelector(focusSel);
if (n) {
if (n && document.activeElement !== n) {
n.focus();
try { n.setSelectionRange(n.value.length, n.value.length); } catch (e) { /* 非 text 元素忽略 */ }
try { n.setSelectionRange(n.value.length, n.value.length); } catch (e2) { /* 非 text 元素忽略 */ }
}
}, 300);
});
});
container.querySelectorAll("[data-p]").forEach((b) => {
b.addEventListener("click", () => { state.page = Number(b.dataset.p); onState(); });
});
const ps = container.querySelector("[data-page-size]");
if (ps) {
ps.value = state.pageSize;
ps.addEventListener("change", () => { state.pageSize = Number(ps.value); state.page = 1; onState(); });
container.addEventListener("change", (e) => {
const dt = container._dt; if (!dt) return;
const ps = e.target.closest("[data-page-size]");
if (ps) { dt.state.pageSize = Number(ps.value); dt.state.page = 1; dt.onState(); }
});
container.addEventListener("compositionstart", (e) => {
const el = e.target;
if (el && el.classList && el.classList.contains("th-filter")) el._composing = true;
});
container.addEventListener("compositionend", (e) => {
const el = e.target;
if (!el || !el.classList || !el.classList.contains("th-filter")) return;
el._composing = false;
// 组合结束提交文本 → 防抖触发一次筛选刷新(覆盖组合期间的输入事件被跳过的情况)
if (el._dbTimer) clearTimeout(el._dbTimer);
el._dbTimer = setTimeout(() => {
const dt = container._dt; if (!dt) return;
dt.onState();
}, 300);
});
// 组合被取消(Esc/切走)时清标志,避免 _composing 卡死导致后续输入不再刷新
container.addEventListener("focusout", (e) => {
const el = e.target;
if (el && el.classList && el.classList.contains("th-filter")) el._composing = false;
});
}

// 5) 数据行(每次重建 tbody 内容)
const tbody = table.querySelector("tbody");
tbody.innerHTML = tableBodyHtml(pageRows, columns);

// 6) 分页器 + 每页行数(每次重建)
const oldPager = container.querySelector(".pager");
if (oldPager) oldPager.remove();
if (pages > 1) {
const pager = document.createElement("div");
pager.className = "pager";
let ph = "";
pagerButtons(state.page, pages).forEach((p) => {
if (p === "…") ph += '<span class="pager-ellipsis">…</span>';
else ph += '<button type="button" class="' + (p === state.page ? "active" : "") + '" data-p="' + p + '">' + p + "</button>";
});
ph += "<span>" + state.page + " / " + pages + " · " + T("tx.pager.count", { n: totalRows }) + "</span>";
pager.innerHTML = ph;
container.appendChild(pager);
}
const oldPs = container.querySelector(".pager-size");
if (oldPs) oldPs.remove();
const psDiv = document.createElement("div");
psDiv.className = "pager-size";
psDiv.innerHTML = T("tx.pager.size") + ' <select data-page-size><option value="5">5</option><option value="10">10</option><option value="25">25</option><option value="50">50</option></select> ' + T("tx.pager.rows");
const psSel = psDiv.querySelector("[data-page-size]");
if (psSel) psSel.value = state.pageSize;
container.appendChild(psDiv);

// 7) 记录最新配置,供容器级事件委托读取
container._dt = { state, onState };
}

/* --- 设置 --- */
Expand Down
Loading