console: 任务列表服务端分页 + 控制台分页面签
任务列表分页(服务端 + 前端): - GET /tasks 新增 limit(1..100,非法回 400)、offset、 order=desc、status/executor/q 服务端过滤;回 total(过滤后 计数)与 status_counts(全项目统计,不受过滤与分页影响, 数据卡片不再把当前页当成全项目);无 limit 时旧行为不变 (全量、正序,协调子任务与旧调用不受影响)。 - 前端每页 20 条、最新在前,上一页/下一页 + 页码;筛选与 搜索改服务端过滤(搜索防抖 300ms),切筛选/切项目/派发 后回第 1 页,10 秒刷新与刷新按钮停在当前页;新测试 test_task_list_pagination.py 5 项。 分页面签: - 顶栏锚点导航改成 5 个页签:工作台(派发 + 任务列表 + 对话 + 详情)、项目(项目 + 成员)、环境与 Workers、 技能与自动化(技能 + 自动化 + 分诊)、管理与 Key; 数据卡片仍在页签上方常驻。选中任务存在 App 层 store 里,切页不丢,回来仍是同一任务、10 秒刷新续同步; 页签同步到 location.hash(#tab-workbench 等),刷新与 分享链接落在同一页签;窄屏工作台仍保持派发在前的顺序。 - 工作台页签仍是左(任务)右(派发/对话/详情)两栏, 沿用等高内部滚动规则;其它页签单列堆叠。
This commit is contained in:
1 parent
9a11ef27b8
commit
e5b6c45c83
11 files changed
+443
-122
No files matched your search
@@ -0,0 +1,135 @@
|
||||
"""任务列表服务端分页:limit/offset/总数/服务端过滤/状态统计。
|
||||
|
||||
用真实 HTTP 服务 + 临时 SQLite:建 5 个任务(2 completed、
|
||||
1 failed、2 queued 交错 prompt),分别断言:
|
||||
- 无 limit 时旧行为不变:全量、created_at 正序、total=5;
|
||||
- limit=2&order=desc 时第 1 页是最新的 2 条,total=5;
|
||||
- offset=2 时第 2 页是接下来 2 条,与第 1 页不重叠;
|
||||
- status 过滤只回该状态,total 是过滤后计数;
|
||||
- q 过滤按 id/prompt 子串(不区分大小写);
|
||||
- limit=0 / limit=101 / offset=-1 回 400;
|
||||
- status_counts 是全项目统计,不受当前过滤与分页影响。
|
||||
"""
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.server import create_server
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.server import create_server
|
||||
|
||||
|
||||
class ListPaginationTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
self.auth = AuthStore(self.db_path)
|
||||
self.owner_id, self.owner_key = self.auth.create_user("owner")
|
||||
self.server = create_server(self.db_path, "127.0.0.1", 0)
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
status, self.project = self._req(
|
||||
"POST", "/projects", {"name": "demo"}, self.owner_key)
|
||||
self.assertEqual(status, 201)
|
||||
self.ids = []
|
||||
for prompt in ["alpha one", "beta two", "alpha three", "gamma four", "alpha five"]:
|
||||
status, t = self._req("POST", "/tasks",
|
||||
{"project_id": self.project["id"], "prompt": prompt},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 201)
|
||||
self.ids.append(t["id"])
|
||||
# 直接改库状态:前 2 条 completed,第 3 条 failed,其余 queued。
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.execute("UPDATE tasks SET status='completed' WHERE id IN (?, ?)",
|
||||
(self.ids[0], self.ids[1]))
|
||||
conn.execute("UPDATE tasks SET status='failed' WHERE id=?", (self.ids[2],))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
try:
|
||||
self.auth.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _req(self, method, path, body=None, key=None):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
||||
hdrs = {}
|
||||
payload = None
|
||||
if body is not None:
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
hdrs["Content-Type"] = "application/json"
|
||||
if key is not None:
|
||||
hdrs["X-API-Key"] = key
|
||||
try:
|
||||
conn.request(method, path, body=payload, headers=hdrs)
|
||||
resp = conn.getresponse()
|
||||
data = resp.read()
|
||||
finally:
|
||||
conn.close()
|
||||
try:
|
||||
parsed = json.loads(data.decode("utf-8")) if data else None
|
||||
except ValueError:
|
||||
parsed = None
|
||||
return resp.status, parsed
|
||||
|
||||
def _list(self, qs=""):
|
||||
return self._req("GET", "/tasks?project_id=" + self.project["id"] + qs,
|
||||
None, self.owner_key)
|
||||
|
||||
def test_no_limit_is_old_behaviour(self):
|
||||
status, d = self._list()
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual([t["id"] for t in d["tasks"]], self.ids)
|
||||
self.assertEqual(d["total"], 5)
|
||||
|
||||
def test_desc_pages_do_not_overlap(self):
|
||||
status, p1 = self._list("&limit=2&order=desc")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(p1["total"], 5)
|
||||
self.assertEqual([t["id"] for t in p1["tasks"]],
|
||||
[self.ids[4], self.ids[3]])
|
||||
status, p2 = self._list("&limit=2&offset=2&order=desc")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual([t["id"] for t in p2["tasks"]],
|
||||
[self.ids[2], self.ids[1]])
|
||||
self.assertEqual(set(t["id"] for t in p1["tasks"]).isdisjoint(
|
||||
set(t["id"] for t in p2["tasks"])), True)
|
||||
|
||||
def test_status_and_q_filters(self):
|
||||
status, d = self._list("&status=completed")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(d["total"], 2)
|
||||
self.assertEqual(set(t["status"] for t in d["tasks"]), {"completed"})
|
||||
status, d = self._list("&q=ALPHA")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(d["total"], 3)
|
||||
|
||||
def test_status_counts_ignore_filter_and_page(self):
|
||||
status, d = self._list("&status=completed&limit=1&order=desc")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(len(d["tasks"]), 1)
|
||||
self.assertEqual(d["status_counts"], {
|
||||
"completed": 2, "failed": 1, "queued": 2})
|
||||
|
||||
def test_invalid_pagination_is_400(self):
|
||||
for qs in ["&limit=0", "&limit=101", "&limit=2&offset=-1", "&limit=abc"]:
|
||||
status, _ = self._list(qs)
|
||||
self.assertEqual(status, 400, qs)
|
||||
Reference in new issue
Block a user