diff --git a/.project-docs/30-worklog/tasks/20260902-kanban-filter-7e3a91c4.md b/.project-docs/30-worklog/tasks/20260902-kanban-filter-7e3a91c4.md
new file mode 100644
index 0000000..c379d0a
--- /dev/null
+++ b/.project-docs/30-worklog/tasks/20260902-kanban-filter-7e3a91c4.md
@@ -0,0 +1,65 @@
+# Task: Fix kanban table filters
+
+## Identity
+
+- Task ID: 20260902-kanban-filter-7e3a91c4
+- Mode: Feature
+- Branch: main
+- Worktree: /Users/inmanx/Documents/lwltAPI
+- Base commit: d034f649c4e7c5d0856f22053b92d2e5a63be5eb
+- Owner: codex
+- Status: Ready for integration
+
+## Scope
+
+- Repair leadership-dashboard table filtering when a business type and/or keyword is submitted.
+- Remove redundant full-range hydration and connection amplification from both ordinary loads and keyword queries.
+- Bound database work and propagate HTTP disconnects into the dashboard read pipeline.
+- Change the dashboard's frontend, API, and service default page size to 20 rows.
+- Add explicit in-flight and timeout feedback plus focused regression coverage.
+
+## Intent And Constraints
+
+- Preserve the existing read-only leadership authorization, date/actor/status semantics, historical business-type inference, and business-facing result projection.
+- Keep full-fidelity task output for rows returned on the current page while using lean candidate projections for filtering and aggregation.
+- Do not modify ERP behavior, task lifecycle state, permissions, runtime services, deployment, or canonical project memory in this Feature task.
+- Coordinate around the ready-for-integration dashboard-metrics task: its display-only summary-card semantics are independent of this filter-form/API repair, although both touch focused dashboard regression files.
+- Do not read `.env`, restart the running service, or perform external writes without separate user authorization.
+
+## Outcome
+
+- Reproduced the reported failure in the already authenticated local dashboard: submitting the form sent the expected `from`, `to`, `status`, `business_route_id`, `search`, `limit`, and `offset` parameters, but the request exceeded the 15-second client deadline and was aborted while unrelated health and task-list requests remained responsive.
+- Confirmed from privacy-safe request diagnostics that dashboard requests alone were accumulating without completion while health, readiness, and task-list endpoints remained responsive. The old implementation could use multiple pool connections per request, hydrate the same candidates repeatedly, and continue database work after the browser's 15-second timeout.
+- Pushed the selected business type into candidate SQL while retaining null-route legacy rows for instruction/operation inference.
+- Replaced pool-level parallel reads with one read-only transaction per dashboard request. The transaction uses one checked-out connection, a 5-second PostgreSQL statement timeout, and a 9-second service query budget.
+- Propagated request/reply disconnects through an `AbortSignal`, stopping all subsequent query and projection stages when the client has gone away. Active database statements remain bounded by the server-side timeout.
+- Removed the ordinary-load classification requery and the keyword path's candidate rehydration query. Ordinary loads fetch encrypted original text only for legacy null-route candidates; keyword loads use the lean persisted receipt/error projection and load historical messages only for relevant candidates not already matched by task fields.
+- Kept full-fidelity detail hydration after pagination and reduced that page from 50 to 20 rows consistently in the browser, API schema, and service fallback.
+- Added privacy-safe per-stage and aggregate dashboard timing/count diagnostics without recording filter values or business content.
+- Added explicit filter-form busy state, disabled the query button during an in-flight request, exposed actionable client and server timeout messages, and advanced the JavaScript cache token.
+- Added a focused regression that protects single-connection read-only execution, database and request bounds, business prefiltering, reduced keyword hydration, page-only detail hydration, 20-row defaults, form busy state, and timeout feedback.
+- Preserved read-only dashboard authorization, historical business-type inference, result/status classification, task pagination, and ERP/task lifecycle behavior.
+
+## Verification
+
+- Passed: `git diff --check`.
+- Passed: `node --check LianSyn-platform/app.js`.
+- Passed: focused dashboard regression, 5/5 tests.
+- Passed: `node --run check` (TypeScript no-emit check).
+- Passed: `node --run test:control-plane`, 156/156 tests.
+- Passed: `node --run check:repo`, 10/10 tests.
+- Passed: `node --run test:legacy`, 265/265 tests.
+- Passed: `node --run build`.
+- Runtime post-change verification was not run because the currently running control-plane process must be restarted to load the TypeScript change, and restart requires separate user authorization.
+
+## Follow-ups
+
+- During integration, reconcile the focused dashboard test, `app.js`, `index.html`, and cache token with ready task `20260902-dashboard-metrics-static-a91c`; its display-only metric-card behavior is semantically independent and should be preserved.
+- After integration and an explicitly authorized service restart, repeat the captured business-type-plus-keyword query and verify it completes before the client deadline with a filtered total and matching table rows.
+
+## Promotion Candidates
+
+- Target canonical document: `.project-docs/10-architecture/data-flow.md` during Integration Gate review.
+- Proposal: document the leadership-dashboard list as a bounded single-connection read pipeline—SQL prefilter and lean candidate projection, historical-message hydration only for unmatched search candidates, then full projection only for the paginated result rows.
+- Evidence: browser network reproduction, privacy-safe server request diagnostics, focused regression, statement/request resource guards, and the full repository verification recorded above.
+- Semantic conflicts: none with AUTH-001, the business-facing dashboard projection, or the concurrent display-only summary-card task.
diff --git a/LianSyn-platform/app-operations-dashboard.test.mjs b/LianSyn-platform/app-operations-dashboard.test.mjs
index 3896a23..e591b0d 100644
--- a/LianSyn-platform/app-operations-dashboard.test.mjs
+++ b/LianSyn-platform/app-operations-dashboard.test.mjs
@@ -2,11 +2,12 @@ import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
-const [app, index, styles, taskService] = await Promise.all([
+const [app, index, styles, taskService, server] = await Promise.all([
readFile(new URL('./app.js', import.meta.url), 'utf8'),
readFile(new URL('./index.html', import.meta.url), 'utf8'),
readFile(new URL('./styles.css', import.meta.url), 'utf8'),
- readFile(new URL('../control-plane/src/task-service.ts', import.meta.url), 'utf8')
+ readFile(new URL('../control-plane/src/task-service.ts', import.meta.url), 'utf8'),
+ readFile(new URL('../control-plane/src/server.ts', import.meta.url), 'utf8')
]);
test('dashboard summary cards share one visual treatment and expose an accessible selected state', () => {
@@ -59,3 +60,47 @@ test('phone dashboard turns metrics and task rows into touch-friendly cards', ()
assert.match(app, /document\.body\.classList\.add\('operations-dashboard-detail-open'\)/);
assert.match(app, /document\.body\.classList\.remove\('operations-dashboard-detail-open'\)/);
});
+
+test('table filters use a bounded single-connection read and default to 20 rows', () => {
+ const dashboardQuery = taskService.slice(
+ taskService.indexOf('async listOperationsDashboard('),
+ taskService.indexOf('async getOperationsDashboardTask(')
+ );
+ const dashboardRoute = server.slice(
+ server.indexOf("app.get('/api/operations-dashboard'"),
+ server.indexOf("app.get('/api/operations-dashboard/tasks/:taskId'")
+ );
+ assert.match(dashboardQuery, /businessRouteId === 'unclassified'[\s\S]*?t\.business_route_id IS NULL/);
+ assert.match(dashboardQuery, /t\.business_route_id = \$\$\{params\.length\} OR t\.business_route_id IS NULL/);
+ assert.match(dashboardQuery, /const taskSearchSelect = `[\s\S]*?t\.success_receipt = '\{\}'::jsonb[\s\S]*?END AS execution_result/);
+ assert.match(dashboardQuery, /withTransaction\(this\.config/);
+ assert.match(dashboardQuery, /SET TRANSACTION READ ONLY/);
+ assert.match(dashboardQuery, /SET LOCAL statement_timeout/);
+ assert.match(dashboardQuery, /SELECT \$\{search \? taskSearchSelect : taskCandidateSelect\}/);
+ assert.match(dashboardQuery, /CASE WHEN t\.business_route_id IS NULL THEN t\.original_text_ciphertext ELSE NULL END/);
+ assert.match(dashboardQuery, /'search_messages'/);
+ assert.match(dashboardQuery, /filter\(\(\{ directSearchMatch \}\) => !directSearchMatch\)/);
+ assert.doesNotMatch(dashboardQuery, /const pool = getPool\(this\.config\)/);
+ assert.doesNotMatch(dashboardQuery, /Promise\.all/);
+ assert.doesNotMatch(dashboardQuery, /loadSearchRows|classificationResult/);
+ assert.match(dashboardQuery, /if \(pageMatches\.length\)[\s\S]*?loadDetailedRows\(pageMatches\.map/);
+ assert.match(dashboardQuery, /options\.limit \|\| 20/);
+ assert.match(app, /const OPERATIONS_DASHBOARD_PAGE_SIZE = 20/);
+ assert.match(server, /operationsDashboardQuerySchema[\s\S]*?limit:[^\n]+default\(20\)/);
+ assert.match(dashboardRoute, /new AbortController\(\)/);
+ assert.match(dashboardRoute, /request\.raw\.once\('aborted', abortRequest\)/);
+ assert.match(dashboardRoute, /signal: controller\.signal/);
+ assert.match(dashboardRoute, /request\.raw\.off\('aborted', abortRequest\)/);
+ assert.match(dashboardQuery, /operations_dashboard_query_timeout/);
+
+ const sync = app.slice(
+ app.indexOf('async function syncOperationsDashboard()'),
+ app.indexOf('async function loadOperationsDashboardTask(')
+ );
+ assert.match(sync, /filterForm\.setAttribute\('aria-busy', 'true'\)/);
+ assert.match(sync, /queryButton\.disabled = true/);
+ assert.match(sync, /filterForm\.removeAttribute\('aria-busy'\)/);
+ assert.match(sync, /queryButton\.disabled = false/);
+ assert.match(app, /看板请求超时,请稍后重试;若正在筛选,请缩短日期范围或增加筛选条件/);
+ assert.match(app, /error\?\.errorCode === 'operations_dashboard_query_timeout'/);
+});
diff --git a/LianSyn-platform/app.js b/LianSyn-platform/app.js
index 7038943..5bc8309 100644
--- a/LianSyn-platform/app.js
+++ b/LianSyn-platform/app.js
@@ -14,7 +14,7 @@ const IS_MANAGEMENT_PAGE = IS_ADMIN_PAGE || IS_OPERATIONS_DASHBOARD_PAGE;
const IS_TASK_PAGE = !IS_MANAGEMENT_PAGE;
const HOME_TASK_LIMIT = 10;
const HISTORY_TASK_PAGE_SIZE = 24;
-const OPERATIONS_DASHBOARD_PAGE_SIZE = 50;
+const OPERATIONS_DASHBOARD_PAGE_SIZE = 20;
const DEFAULT_API_TIMEOUT_MS = 15_000;
const AUTH_API_TIMEOUT_MS = 10_000;
const RESULT_API_TIMEOUT_MS = 20_000;
@@ -1315,6 +1315,12 @@ function operationsDashboardReadableResult(value, outcomeKind) {
function operationsDashboardSafeError(error, fallback = '看板暂时无法更新,请稍后再试。') {
const message = String(error?.message || '').trim();
+ if (error?.errorCode === 'operations_dashboard_query_timeout') {
+ return '看板查询耗时过长,请缩短日期范围或增加筛选条件后重试。';
+ }
+ if (error?.code === 'request_timeout' || /^请求超时/.test(message)) {
+ return '看板请求超时,请稍后重试;若正在筛选,请缩短日期范围或增加筛选条件。';
+ }
if (/^(请选择有效的开始与结束日期|结束日期不能早于开始日期|单次最多查询|关键词查询范围)/.test(message)) {
return message;
}
@@ -1621,6 +1627,10 @@ async function syncOperationsDashboard() {
renderOperationsDashboardTasks();
const message = $('#operationsDashboardMessage');
const refreshButton = $('#operationsDashboardRefresh');
+ const filterForm = $('#operationsDashboardFilters');
+ const queryButton = filterForm?.querySelector('button[type="submit"]');
+ if (filterForm) filterForm.setAttribute('aria-busy', 'true');
+ if (queryButton) queryButton.disabled = true;
if (refreshButton) refreshButton.disabled = true;
if (message) message.textContent = '正在汇总平台运行数据…';
try {
@@ -1648,6 +1658,8 @@ async function syncOperationsDashboard() {
if (message) message.textContent = '';
} finally {
operationsDashboardBusy = false;
+ if (filterForm) filterForm.removeAttribute('aria-busy');
+ if (queryButton) queryButton.disabled = false;
if (refreshButton) refreshButton.disabled = false;
renderOperationsDashboardTasks();
}
diff --git a/LianSyn-platform/index.html b/LianSyn-platform/index.html
index 3cb35c6..e487b0c 100644
--- a/LianSyn-platform/index.html
+++ b/LianSyn-platform/index.html
@@ -405,6 +405,6 @@
-
+