feat(web): dropdown sort and time filters with year support
This commit is contained in:
1 parent
bc3bef0743
commit
9be7d1cab2
10 files changed
+186
-62
No files matched your search
+5
-5
@@ -526,11 +526,11 @@ P3 实现说明:
|
|||||||
| 方法 | 路径 | 用途 |
|
| 方法 | 路径 | 用途 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/api/overview` | 首页动态、总览指标、容量、采集状态 |
|
| `GET` | `/api/overview` | 首页动态、总览指标、容量、采集状态 |
|
||||||
| `GET` | `/api/videos` | 视频分页、筛选和排序(支持 added/published/heat/七项/互动/增长/内容效率) |
|
| `GET` | `/api/videos` | 视频分页、筛选和排序(`sort` / `within_days` / `year` / `q`;支持 added/published/heat/七项/互动/增长/内容效率) |
|
||||||
| `GET` | `/api/videos/{aid}` | 视频详情(含分 P 数、入库时粉丝数、内容/互动效率) |
|
| `GET` | `/api/videos/{aid}` | 视频详情(含分 P 数、入库时粉丝数、内容/互动效率) |
|
||||||
| `GET` | `/api/videos/{aid}/trend` | 七项趋势数据 |
|
| `GET` | `/api/videos/{aid}/trend` | 七项趋势数据 |
|
||||||
| `GET` | `/api/rankings` | 兼容旧链接;`scope=up` 时为 UP 聚合榜 |
|
| `GET` | `/api/rankings` | 兼容旧链接;`scope=up` 时为 UP 聚合榜(`year` 可用) |
|
||||||
| `GET` | `/api/ups` | UP 聚合榜单(含总时长、播放·小时、内容效率) |
|
| `GET` | `/api/ups` | UP 聚合榜单(含总时长、播放·小时、内容效率;`sort` / `within_days` / `year`) |
|
||||||
| `GET` | `/api/ups/{mid}` | UP 详情、聚合趋势和视频列表 |
|
| `GET` | `/api/ups/{mid}` | UP 详情、聚合趋势和视频列表 |
|
||||||
| `GET` | `/api/fans` | 粉丝曲线与增量 |
|
| `GET` | `/api/fans` | 粉丝曲线与增量 |
|
||||||
| `GET` | `/api/candidates` | 候选池(只读) |
|
| `GET` | `/api/candidates` | 候选池(只读) |
|
||||||
@@ -542,8 +542,8 @@ P3 实现说明:
|
|||||||
| 页面 | 内容 |
|
| 页面 | 内容 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 首页 | 最新动态优先:今日增量、最新入库、最近发布、增长动态、热度速览、容量与采集状态。 |
|
| 首页 | 最新动态优先:今日增量、最新入库、最近发布、增长动态、热度速览、容量与采集状态。 |
|
||||||
| 视频(`/videos`) | 内容效率(默认)、综合热度、播放/投币/收藏/评论、互动率、播放增量、播放增速;筛选与搜索。 |
|
| 视频(`/videos`) | 内容效率(默认)、综合热度、发布时间、入库时间、播放/投币/收藏/评论、互动率、播放增量、播放增速;排序与时间筛选为下拉框,时间支持全部/7 天/30 天/90 天/1 年/年份。 |
|
||||||
| UP主(`/ups`) | 独立入口,不再与视频混排;视频数、总时长、播放·小时、互动·小时等可分别排序。 |
|
| UP主(`/ups`) | 独立入口,不再与视频混排;排序下拉含内容效率、互动效率、内容量、总播放、视频数、平均播放、总互动、最近发布。 |
|
||||||
| 数据说明(`/about`) | 数据来源与更新、发布时间/入库时间/快照日期、指标公式、时长与成熟窗口筛选、粉丝快照覆盖与已知局限;数值由 `/api/about` 动态读取。 |
|
| 数据说明(`/about`) | 数据来源与更新、发布时间/入库时间/快照日期、指标公式、时长与成熟窗口筛选、粉丝快照覆盖与已知局限;数值由 `/api/about` 动态读取。 |
|
||||||
| 视频详情 | 元数据、分 P 数、七项指标、内容/互动效率、入库时粉丝数、单指标趋势、快照表、同 UP 对比。 |
|
| 视频详情 | 元数据、分 P 数、七项指标、内容/互动效率、入库时粉丝数、单指标趋势、快照表、同 UP 对比。 |
|
||||||
| UP 页 | 该 UP 的全部视频、聚合趋势、单视频对比、总时长与播放·小时、入库时与当前粉丝数。 |
|
| UP 页 | 该 UP 的全部视频、聚合趋势、单视频对比、总时长与播放·小时、入库时与当前粉丝数。 |
|
||||||
|
|||||||
@@ -171,7 +171,9 @@ uv run python app.py serve --preview --config data/preview/config.json
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 首页默认“最新动态”:今日增量、最新入库、最近发布、增长动态、热度速览、收藏夹容量与采集状态;
|
- 首页默认“最新动态”:今日增量、最新入库、最近发布、增长动态、热度速览、收藏夹容量与采集状态;
|
||||||
- 「视频」与「UP主」是两个独立入口:视频榜支持内容效率(默认)、综合热度、播放/投币/收藏/评论、互动率、播放增量/播放增速;UP 榜展示视频数、总时长、总播放、播放·小时、互动·小时等,可分别排序;
|
- 「视频」与「UP主」是两个独立入口;搜索框固定在约 230px,排序与时间筛选都是下拉框,可放在同一行;
|
||||||
|
- 视频排序:内容效率(默认)、综合热度、发布时间、入库时间、播放/投币/收藏/评论、互动率、播放增量/播放增速;UP 排序:内容效率、互动效率、内容量、总播放、视频数、平均播放、总互动、最近发布;
|
||||||
|
- 时间筛选:全部 / 近 7 天 / 近 30 天 / 近 90 天 / 近 1 年 / 已入库的年份;不再提供按收藏夹过滤;
|
||||||
- 内容效率 = 播放 / 内容小时;单条视频用 60 分钟先验暴露量向全库均值收缩,只统计时长 ≥ 600 秒、发布满 2 天、播放 ≥ 2000 的视频(综合热度榜同样要求播放 ≥ 2000);UP 内容效率用经验贝叶斯收缩,避免极小样本冲榜;
|
- 内容效率 = 播放 / 内容小时;单条视频用 60 分钟先验暴露量向全库均值收缩,只统计时长 ≥ 600 秒、发布满 2 天、播放 ≥ 2000 的视频(综合热度榜同样要求播放 ≥ 2000);UP 内容效率用经验贝叶斯收缩,避免极小样本冲榜;
|
||||||
- 「数据说明」(`/about`)说明数据来源、发布时间/入库时间/快照日期、指标公式、筛选口径与已知局限,数值由 `/api/about` 动态读取;
|
- 「数据说明」(`/about`)说明数据来源、发布时间/入库时间/快照日期、指标公式、筛选口径与已知局限,数值由 `/api/about` 动态读取;
|
||||||
- 视频详情提供分 P 数、七项指标、内容/互动效率、入库时粉丝数、单指标趋势、快照表和同 UP 对比;UP 页提供聚合趋势、单视频对比与入库时/当前粉丝数;
|
- 视频详情提供分 P 数、七项指标、内容/互动效率、入库时粉丝数、单指标趋势、快照表和同 UP 对比;UP 页提供聚合趋势、单视频对比与入库时/当前粉丝数;
|
||||||
|
|||||||
+4
-3
@@ -701,8 +701,8 @@ UP 内容效率 = EB 收缩后的 Σview / Σ内容小时
|
|||||||
| 页面 | 内容 |
|
| 页面 | 内容 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 首页 | 最新动态优先:今日增量、最新入库、最近发布、增长动态、热度速览、收藏夹容量、采集状态 |
|
| 首页 | 最新动态优先:今日增量、最新入库、最近发布、增长动态、热度速览、收藏夹容量、采集状态 |
|
||||||
| **视频** | Tab:内容效率/综合热度/播放/投币/收藏/评论/互动率/播放增量/播放增速;筛选与搜索 |
|
| **视频** | 排序下拉:内容效率(默认)/综合热度/发布时间/入库时间/播放/投币/收藏/评论/互动率/播放增量/播放增速;时间下拉:全部/7 天/30 天/90 天/1 年/年份;搜索框约 230px;不再按收藏夹过滤 |
|
||||||
| **UP主** | 独立入口;列:视频数、总时长、总播放、播放·小时、互动·小时等,可分别排序 |
|
| **UP主** | 独立入口;排序下拉:内容效率/互动效率/内容量/总播放/视频数/平均播放/总互动/最近发布;时间下拉同上 |
|
||||||
| 数据说明 | 数据来源与更新节奏、三个时间概念、指标公式、时长/成熟窗口筛选、粉丝快照覆盖与已知局限;数值由 `/api/about` 动态读取当前配置 |
|
| 数据说明 | 数据来源与更新节奏、三个时间概念、指标公式、时长/成熟窗口筛选、粉丝快照覆盖与已知局限;数值由 `/api/about` 动态读取当前配置 |
|
||||||
| 视频详情 | 元数据 + 分 P 数 + 七项指标 + 内容/互动效率 + 入库时粉丝数 + 单指标趋势 + 同 UP 对比 + 快照表 |
|
| 视频详情 | 元数据 + 分 P 数 + 七项指标 + 内容/互动效率 + 入库时粉丝数 + 单指标趋势 + 同 UP 对比 + 快照表 |
|
||||||
| UP 页 | 该 UP 全部视频、聚合趋势、单视频对比、总时长/播放·小时、入库时与当前粉丝数 |
|
| UP 页 | 该 UP 全部视频、聚合趋势、单视频对比、总时长/播放·小时、入库时与当前粉丝数 |
|
||||||
@@ -843,6 +843,7 @@ storage:SQLite WAL,单写线程
|
|||||||
| 40 | **导航拆分与数据说明页** | `/videos`、`/ups`、`/about` 均返回 200;`/api/about` 返回 schema 4、当前阈值、权重与粉丝覆盖度 |
|
| 40 | **导航拆分与数据说明页** | `/videos`、`/ups`、`/about` 均返回 200;`/api/about` 返回 schema 4、当前阈值、权重与粉丝覆盖度 |
|
||||||
| 41 | **全量离线测试** | 32 个 `tests/test_*.py` 全部通过(含新增 `test_up_profile.py` 与迁移/数据库断言更新) |
|
| 41 | **全量离线测试** | 32 个 `tests/test_*.py` 全部通过(含新增 `test_up_profile.py` 与迁移/数据库断言更新) |
|
||||||
| 42 | **内容效率噪声修正** | 预览库实测:视频级 EB 收缩(κ=60 分钟)+ 播放 ≥ 2000 后,效率榜 Top10 全为 ≥110 分钟视频;「什么哦吼吼」4 条不再入榜;综合热度榜同样应用 2000 门槛(637 条) |
|
| 42 | **内容效率噪声修正** | 预览库实测:视频级 EB 收缩(κ=60 分钟)+ 播放 ≥ 2000 后,效率榜 Top10 全为 ≥110 分钟视频;「什么哦吼吼」4 条不再入榜;综合热度榜同样应用 2000 门槛(637 条) |
|
||||||
|
| 43 | **榜单交互改版** | 排序/时间改为下拉;时间筛选实测 `year=2026` 640 条、`year=2025` 156 条(合计 796);`sort=published` 可用;`/api/overview` 返回 years `[2026,2025]`;`year=1800` 返回 400 |
|
||||||
|
|
||||||
**未验证(需授权或外部配合)**:
|
**未验证(需授权或外部配合)**:
|
||||||
|
|
||||||
@@ -867,7 +868,7 @@ P0、P1、P2、P3 与第 9 轮已实施;P2 交付了只读本地看板与可
|
|||||||
1. schema v4:`video.pages`、`video.up_follower*`、`up_profile`;
|
1. schema v4:`video.pages`、`video.up_follower*`、`up_profile`;
|
||||||
2. 入库时采集 UP 粉丝快照 + `python app.py backfill-followers` 历史回填(基于粉丝数的榜单暂不做);
|
2. 入库时采集 UP 粉丝快照 + `python app.py backfill-followers` 历史回填(基于粉丝数的榜单暂不做);
|
||||||
3. 时长效率榜(内容效率/互动效率)与 UP 内容效率(EB 收缩),过滤 < 600 秒与未满 2 天的视频;
|
3. 时长效率榜(内容效率/互动效率)与 UP 内容效率(EB 收缩),过滤 < 600 秒与未满 2 天的视频;
|
||||||
4. 主导航拆分:「榜单」→「视频」+「UP主」,两者独立页面;新增「数据说明」页(`/about` + `/api/about`);
|
4. 主导航拆分:「榜单」→「视频」+「UP主」,两者独立页面;新增「数据说明」页(`/about` + `/api/about`);排序/时间改为下拉筛选,支持按发布时间排序与按年份过滤;
|
||||||
5. 列表精简:卡片只保留主指标 + 互动率,七项明细与分 P、粉丝快照移到详情页。
|
5. 列表精简:卡片只保留主指标 + 互动率,七项明细与分 P、粉丝快照移到详情页。
|
||||||
|
|
||||||
仍待处理:
|
仍待处理:
|
||||||
|
|||||||
@@ -286,6 +286,7 @@ class DashboardQueries:
|
|||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
within_field: str | None = None,
|
within_field: str | None = None,
|
||||||
within_start: str | None = None,
|
within_start: str | None = None,
|
||||||
|
within_end: str | None = None,
|
||||||
up_mid: int | None = None,
|
up_mid: int | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
clauses = [f"v.status IN ({','.join('?' for _ in statuses)})"]
|
clauses = [f"v.status IN ({','.join('?' for _ in statuses)})"]
|
||||||
@@ -302,12 +303,17 @@ class DashboardQueries:
|
|||||||
"(v.title LIKE ? ESCAPE '\\' OR v.up_name LIKE ? ESCAPE '\\')"
|
"(v.title LIKE ? ESCAPE '\\' OR v.up_name LIKE ? ESCAPE '\\')"
|
||||||
)
|
)
|
||||||
params.extend([pattern, pattern])
|
params.extend([pattern, pattern])
|
||||||
if within_field and within_start:
|
if within_field:
|
||||||
if within_field not in {"pubdate", "first_seen"}:
|
if within_field not in {"pubdate", "first_seen"}:
|
||||||
raise ValueError("within_field 必须是 pubdate 或 first_seen")
|
raise ValueError("within_field 必须是 pubdate 或 first_seen")
|
||||||
|
if within_start:
|
||||||
start_ts, _ = day_bounds(within_start)
|
start_ts, _ = day_bounds(within_start)
|
||||||
clauses.append(f"v.{within_field} >= ?")
|
clauses.append(f"v.{within_field} >= ?")
|
||||||
params.append(start_ts)
|
params.append(start_ts)
|
||||||
|
if within_end:
|
||||||
|
end_ts, _ = day_bounds(within_end)
|
||||||
|
clauses.append(f"v.{within_field} < ?")
|
||||||
|
params.append(end_ts)
|
||||||
sql = (
|
sql = (
|
||||||
"SELECT v.*, f.title AS folder_title, f.year AS folder_year "
|
"SELECT v.*, f.title AS folder_title, f.year AS folder_year "
|
||||||
"FROM video v LEFT JOIN folder f ON f.media_id = v.folder_id "
|
"FROM video v LEFT JOIN folder f ON f.media_id = v.folder_id "
|
||||||
@@ -490,6 +496,14 @@ class DashboardQueries:
|
|||||||
cfg = self.config_store.get()
|
cfg = self.config_store.get()
|
||||||
fans = self._fans_overview(conn, cfg.track_fans, latest, snapshot_days)
|
fans = self._fans_overview(conn, cfg.track_fans, latest, snapshot_days)
|
||||||
folders = self._folders_overview(conn)
|
folders = self._folders_overview(conn)
|
||||||
|
pub_years = sorted(
|
||||||
|
{
|
||||||
|
int(shanghai_date(int(video["pubdate"]))[:4])
|
||||||
|
for video in videos
|
||||||
|
if int(video.get("pubdate") or 0) > 0
|
||||||
|
},
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
tasks = self._task_records(conn)
|
tasks = self._task_records(conn)
|
||||||
auth = {
|
auth = {
|
||||||
"ok": self._kv(conn, "auth:ok"),
|
"ok": self._kv(conn, "auth:ok"),
|
||||||
@@ -527,6 +541,7 @@ class DashboardQueries:
|
|||||||
"snapshot_completed_today": snapshot_completed_today,
|
"snapshot_completed_today": snapshot_completed_today,
|
||||||
"fans": fans,
|
"fans": fans,
|
||||||
"folders": folders,
|
"folders": folders,
|
||||||
|
"years": pub_years,
|
||||||
"tasks": tasks,
|
"tasks": tasks,
|
||||||
"auth": auth,
|
"auth": auth,
|
||||||
"preview": preview,
|
"preview": preview,
|
||||||
@@ -650,6 +665,7 @@ class DashboardQueries:
|
|||||||
*,
|
*,
|
||||||
sort: str = "heat",
|
sort: str = "heat",
|
||||||
within_days: int | None = None,
|
within_days: int | None = None,
|
||||||
|
year: int | None = None,
|
||||||
growth_days: int = 7,
|
growth_days: int = 7,
|
||||||
folder_id: int | None = None,
|
folder_id: int | None = None,
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
@@ -659,6 +675,7 @@ class DashboardQueries:
|
|||||||
params = {
|
params = {
|
||||||
"sort": sort,
|
"sort": sort,
|
||||||
"within_days": within_days,
|
"within_days": within_days,
|
||||||
|
"year": year,
|
||||||
"growth_days": growth_days,
|
"growth_days": growth_days,
|
||||||
"folder_id": folder_id,
|
"folder_id": folder_id,
|
||||||
"q": q or "",
|
"q": q or "",
|
||||||
@@ -669,6 +686,7 @@ class DashboardQueries:
|
|||||||
"videos", params, TTL_VIDEOS, lambda: self._list_videos_uncached(
|
"videos", params, TTL_VIDEOS, lambda: self._list_videos_uncached(
|
||||||
sort=sort,
|
sort=sort,
|
||||||
within_days=within_days,
|
within_days=within_days,
|
||||||
|
year=year,
|
||||||
growth_days=growth_days,
|
growth_days=growth_days,
|
||||||
folder_id=folder_id,
|
folder_id=folder_id,
|
||||||
q=q,
|
q=q,
|
||||||
@@ -682,6 +700,7 @@ class DashboardQueries:
|
|||||||
*,
|
*,
|
||||||
sort: str = "heat",
|
sort: str = "heat",
|
||||||
within_days: int | None = None,
|
within_days: int | None = None,
|
||||||
|
year: int | None = None,
|
||||||
growth_days: int = 7,
|
growth_days: int = 7,
|
||||||
folder_id: int | None = None,
|
folder_id: int | None = None,
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
@@ -701,19 +720,24 @@ class DashboardQueries:
|
|||||||
duration_sorts = {"efficiency", "interaction_efficiency"}
|
duration_sorts = {"efficiency", "interaction_efficiency"}
|
||||||
min_view_for_ranking = max(0, int(analytics.min_view_for_ranking))
|
min_view_for_ranking = max(0, int(analytics.min_view_for_ranking))
|
||||||
within_field = None
|
within_field = None
|
||||||
if within_days is not None:
|
within_start: str | None = None
|
||||||
|
within_end: str | None = None
|
||||||
|
if year is not None:
|
||||||
|
within_field = "pubdate"
|
||||||
|
within_start = f"{int(year)}-01-01"
|
||||||
|
within_end = f"{int(year) + 1}-01-01"
|
||||||
|
elif within_days is not None:
|
||||||
if within_days < 1:
|
if within_days < 1:
|
||||||
raise ValueError("within_days 必须大于 0")
|
raise ValueError("within_days 必须大于 0")
|
||||||
within_field = "first_seen" if sort == "added" else "pubdate"
|
within_field = "first_seen" if sort == "added" else "pubdate"
|
||||||
|
within_start = _date_minus(latest, int(within_days) - 1)
|
||||||
videos = self._videos(
|
videos = self._videos(
|
||||||
conn,
|
conn,
|
||||||
folder_id=folder_id,
|
folder_id=folder_id,
|
||||||
q=q,
|
q=q,
|
||||||
within_field=within_field,
|
within_field=within_field,
|
||||||
within_start=(
|
within_start=within_start,
|
||||||
_date_minus(latest, int(within_days) - 1)
|
within_end=within_end,
|
||||||
if within_days is not None else None
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
aids = [int(video["aid"]) for video in videos]
|
aids = [int(video["aid"]) for video in videos]
|
||||||
snapshots = self._snapshot_map(conn, aids)
|
snapshots = self._snapshot_map(conn, aids)
|
||||||
@@ -862,6 +886,7 @@ class DashboardQueries:
|
|||||||
return {
|
return {
|
||||||
"sort": sort,
|
"sort": sort,
|
||||||
"within_days": within_days,
|
"within_days": within_days,
|
||||||
|
"year": year,
|
||||||
"growth_days": growth_days,
|
"growth_days": growth_days,
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": page_size,
|
"page_size": page_size,
|
||||||
@@ -973,6 +998,7 @@ class DashboardQueries:
|
|||||||
*,
|
*,
|
||||||
sort: str = "avg_views",
|
sort: str = "avg_views",
|
||||||
within_days: int | None = None,
|
within_days: int | None = None,
|
||||||
|
year: int | None = None,
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
min_videos: int = 1,
|
min_videos: int = 1,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
@@ -981,6 +1007,7 @@ class DashboardQueries:
|
|||||||
params = {
|
params = {
|
||||||
"sort": sort,
|
"sort": sort,
|
||||||
"within_days": within_days,
|
"within_days": within_days,
|
||||||
|
"year": year,
|
||||||
"q": q or "",
|
"q": q or "",
|
||||||
"min_videos": min_videos,
|
"min_videos": min_videos,
|
||||||
"page": page,
|
"page": page,
|
||||||
@@ -990,6 +1017,7 @@ class DashboardQueries:
|
|||||||
"videos", params, TTL_VIDEOS, lambda: self._list_ups_uncached(
|
"videos", params, TTL_VIDEOS, lambda: self._list_ups_uncached(
|
||||||
sort=sort,
|
sort=sort,
|
||||||
within_days=within_days,
|
within_days=within_days,
|
||||||
|
year=year,
|
||||||
q=q,
|
q=q,
|
||||||
min_videos=min_videos,
|
min_videos=min_videos,
|
||||||
page=page,
|
page=page,
|
||||||
@@ -1002,6 +1030,7 @@ class DashboardQueries:
|
|||||||
*,
|
*,
|
||||||
sort: str = "avg_views",
|
sort: str = "avg_views",
|
||||||
within_days: int | None = None,
|
within_days: int | None = None,
|
||||||
|
year: int | None = None,
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
min_videos: int = 1,
|
min_videos: int = 1,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
@@ -1015,14 +1044,24 @@ class DashboardQueries:
|
|||||||
|
|
||||||
def run(conn: sqlite3.Connection) -> dict[str, Any]:
|
def run(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||||
latest = self._latest_date(conn) or self._today()
|
latest = self._latest_date(conn) or self._today()
|
||||||
|
if year is not None:
|
||||||
|
within_field = "pubdate"
|
||||||
|
within_start: str | None = f"{int(year)}-01-01"
|
||||||
|
within_end: str | None = f"{int(year) + 1}-01-01"
|
||||||
|
elif within_days is not None:
|
||||||
|
within_field = "pubdate"
|
||||||
|
within_start = _date_minus(latest, int(within_days) - 1)
|
||||||
|
within_end = None
|
||||||
|
else:
|
||||||
|
within_field = None
|
||||||
|
within_start = None
|
||||||
|
within_end = None
|
||||||
videos = self._videos(
|
videos = self._videos(
|
||||||
conn,
|
conn,
|
||||||
q=q,
|
q=q,
|
||||||
within_field="pubdate" if within_days is not None else None,
|
within_field=within_field,
|
||||||
within_start=(
|
within_start=within_start,
|
||||||
_date_minus(latest, int(within_days) - 1)
|
within_end=within_end,
|
||||||
if within_days is not None else None
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
snapshots = self._snapshot_map(conn, [int(video["aid"]) for video in videos])
|
snapshots = self._snapshot_map(conn, [int(video["aid"]) for video in videos])
|
||||||
groups: dict[int, dict[str, Any]] = {}
|
groups: dict[int, dict[str, Any]] = {}
|
||||||
@@ -1147,6 +1186,7 @@ class DashboardQueries:
|
|||||||
return {
|
return {
|
||||||
"sort": sort,
|
"sort": sort,
|
||||||
"within_days": within_days,
|
"within_days": within_days,
|
||||||
|
"year": year,
|
||||||
"min_videos": min_videos,
|
"min_videos": min_videos,
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": page_size,
|
"page_size": page_size,
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}}
|
|||||||
self._send_json(HTTPStatus.OK, queries.list_videos(
|
self._send_json(HTTPStatus.OK, queries.list_videos(
|
||||||
sort=_param(query, "sort", "heat"),
|
sort=_param(query, "sort", "heat"),
|
||||||
within_days=_optional_int(query, "within_days"),
|
within_days=_optional_int(query, "within_days"),
|
||||||
|
year=_year_param(query),
|
||||||
growth_days=_int_param(query, "growth_days", 7, 1, 365),
|
growth_days=_int_param(query, "growth_days", 7, 1, 365),
|
||||||
folder_id=_optional_int(query, "folder"),
|
folder_id=_optional_int(query, "folder"),
|
||||||
q=_optional_str(query, "q"),
|
q=_optional_str(query, "q"),
|
||||||
@@ -326,6 +327,7 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}}
|
|||||||
self._send_json(HTTPStatus.OK, queries.list_ups(
|
self._send_json(HTTPStatus.OK, queries.list_ups(
|
||||||
sort=_param(query, "sort", "efficiency"),
|
sort=_param(query, "sort", "efficiency"),
|
||||||
within_days=_optional_int(query, "within_days"),
|
within_days=_optional_int(query, "within_days"),
|
||||||
|
year=_year_param(query),
|
||||||
q=_optional_str(query, "q"),
|
q=_optional_str(query, "q"),
|
||||||
min_videos=_int_param(query, "min_videos", 1, 1, 10000),
|
min_videos=_int_param(query, "min_videos", 1, 1, 10000),
|
||||||
page=_int_param(query, "page", 1, 1, 1_000_000),
|
page=_int_param(query, "page", 1, 1, 1_000_000),
|
||||||
@@ -341,6 +343,7 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}}
|
|||||||
self._send_json(HTTPStatus.OK, queries.list_videos(
|
self._send_json(HTTPStatus.OK, queries.list_videos(
|
||||||
sort=_param(query, "sort", "heat"),
|
sort=_param(query, "sort", "heat"),
|
||||||
within_days=_optional_int(query, "within_days"),
|
within_days=_optional_int(query, "within_days"),
|
||||||
|
year=_year_param(query),
|
||||||
growth_days=_int_param(query, "growth_days", 7, 1, 365),
|
growth_days=_int_param(query, "growth_days", 7, 1, 365),
|
||||||
folder_id=_optional_int(query, "folder"),
|
folder_id=_optional_int(query, "folder"),
|
||||||
q=_optional_str(query, "q"),
|
q=_optional_str(query, "q"),
|
||||||
@@ -356,6 +359,7 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}}
|
|||||||
self._send_json(HTTPStatus.OK, queries.list_ups(
|
self._send_json(HTTPStatus.OK, queries.list_ups(
|
||||||
sort=_param(query, "sort", "efficiency"),
|
sort=_param(query, "sort", "efficiency"),
|
||||||
within_days=_optional_int(query, "within_days"),
|
within_days=_optional_int(query, "within_days"),
|
||||||
|
year=_year_param(query),
|
||||||
q=_optional_str(query, "q"),
|
q=_optional_str(query, "q"),
|
||||||
min_videos=_int_param(query, "min_videos", 1, 1, 10000),
|
min_videos=_int_param(query, "min_videos", 1, 1, 10000),
|
||||||
page=_int_param(query, "page", 1, 1, 1_000_000),
|
page=_int_param(query, "page", 1, 1, 1_000_000),
|
||||||
@@ -526,6 +530,15 @@ def _optional_int(query: Mapping[str, list[str]], name: str) -> int | None:
|
|||||||
raise ApiError(HTTPStatus.BAD_REQUEST, "bad_request", f"{name} 必须是整数") from exc
|
raise ApiError(HTTPStatus.BAD_REQUEST, "bad_request", f"{name} 必须是整数") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _year_param(query: Mapping[str, list[str]]) -> int | None:
|
||||||
|
value = _optional_int(query, "year")
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value < 2000 or value > 2100:
|
||||||
|
raise ApiError(HTTPStatus.BAD_REQUEST, "bad_request", "year 必须在 2000~2100 之间")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _int_param(
|
def _int_param(
|
||||||
query: Mapping[str, list[str]],
|
query: Mapping[str, list[str]],
|
||||||
name: str,
|
name: str,
|
||||||
|
|||||||
@@ -520,10 +520,20 @@ h1, h2, h3, p { margin: 0; }
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.rank-search { display: flex; flex: 1; min-width: 0; gap: 6px; }
|
.rank-search { display: flex; flex: 0 0 auto; width: 230px; gap: 6px; }
|
||||||
.rank-search .search-input { flex: 1; min-width: 0; }
|
.rank-search .search-input { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.select-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.rank-toolbar .select { max-width: 220px; width: auto; }
|
||||||
@media (max-width: 619px) {
|
@media (max-width: 619px) {
|
||||||
.rank-search { flex-basis: 100%; }
|
.rank-search { width: 230px; }
|
||||||
|
.rank-toolbar .select { max-width: 100%; }
|
||||||
}
|
}
|
||||||
.search-input, .select {
|
.search-input, .select {
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export async function renderHome(root) {
|
|||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>最近发布</h2>
|
<h2>最近发布</h2>
|
||||||
<a class="text-link" href="/videos?sort=published&within_days=14">查看全部</a>
|
<a class="text-link" href="/videos?sort=published&within_days=30">查看全部</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="section-note">按视频发布时间排序,与入库时间无关。</p>
|
<p class="section-note">按视频发布时间排序,与入库时间无关。</p>
|
||||||
<div class="video-list">
|
<div class="video-list">
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { replaceQuery } from "../router.js";
|
|||||||
const VIDEO_SORTS = [
|
const VIDEO_SORTS = [
|
||||||
["efficiency", "内容效率"],
|
["efficiency", "内容效率"],
|
||||||
["heat", "综合热度"],
|
["heat", "综合热度"],
|
||||||
|
["published", "发布时间"],
|
||||||
|
["added", "入库时间"],
|
||||||
["view", "播放"],
|
["view", "播放"],
|
||||||
["coin", "投币"],
|
["coin", "投币"],
|
||||||
["favorite", "收藏"],
|
["favorite", "收藏"],
|
||||||
@@ -26,6 +28,13 @@ const UP_SORTS = [
|
|||||||
["interaction", "总互动"],
|
["interaction", "总互动"],
|
||||||
["latest", "最近发布"],
|
["latest", "最近发布"],
|
||||||
];
|
];
|
||||||
|
const TIME_RANGES = [
|
||||||
|
["all", "全部时间"],
|
||||||
|
["7", "近 7 天"],
|
||||||
|
["30", "近 30 天"],
|
||||||
|
["90", "近 90 天"],
|
||||||
|
["365", "近 1 年"],
|
||||||
|
];
|
||||||
const VIDEO_METRIC_PRIMARY = new Set(["view", "coin", "favorite", "reply"]);
|
const VIDEO_METRIC_PRIMARY = new Set(["view", "coin", "favorite", "reply"]);
|
||||||
const OVERVIEW_TTL = 60_000;
|
const OVERVIEW_TTL = 60_000;
|
||||||
let overviewCache = { at: 0, promise: null };
|
let overviewCache = { at: 0, promise: null };
|
||||||
@@ -43,8 +52,22 @@ async function getOverview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function controls({ scope, sort, within, growthDays, q, folder, minVideos, folders }) {
|
function sortOptions(scope, sort) {
|
||||||
const sorts = scope === "up" ? UP_SORTS : VIDEO_SORTS;
|
const sorts = scope === "up" ? UP_SORTS : VIDEO_SORTS;
|
||||||
|
return sorts.map(([value, label]) =>
|
||||||
|
`<option value="${value}" ${value === sort ? "selected" : ""}>${escapeHtml(label)}</option>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeOptions(within, year, years) {
|
||||||
|
const selected = year ? `year:${year}` : (within || "all");
|
||||||
|
const base = TIME_RANGES.map(([value, label]) =>
|
||||||
|
`<option value="${value}" ${selected === value ? "selected" : ""}>${escapeHtml(label)}</option>`).join("");
|
||||||
|
const yearOptions = (years || []).map((value) =>
|
||||||
|
`<option value="year:${value}" ${selected === `year:${value}` ? "selected" : ""}>${value} 年</option>`).join("");
|
||||||
|
return base + yearOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function controls({ scope, sort, within, year, growthDays, q, minVideos, years }) {
|
||||||
return `
|
return `
|
||||||
<div class="rank-toolbar">
|
<div class="rank-toolbar">
|
||||||
<form class="rank-search" id="rank-filter">
|
<form class="rank-search" id="rank-filter">
|
||||||
@@ -52,25 +75,19 @@ function controls({ scope, sort, within, growthDays, q, folder, minVideos, folde
|
|||||||
placeholder="${scope === "up" ? "搜索 UP 名" : "搜索标题或 UP"}" autocomplete="off">
|
placeholder="${scope === "up" ? "搜索 UP 名" : "搜索标题或 UP"}" autocomplete="off">
|
||||||
<button class="button" type="submit">搜索</button>
|
<button class="button" type="submit">搜索</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
<label class="select-label">排序
|
||||||
<div class="chip-row scroll-x">
|
<select class="select" id="rank-sort">${sortOptions(scope, sort)}</select>
|
||||||
${sorts.map(([value, label]) => chip(label, sort === value, "sort", value)).join("")}
|
</label>
|
||||||
</div>
|
<label class="select-label">时间
|
||||||
<div class="filter-row">
|
<select class="select" id="rank-time">${timeOptions(within, year, years)}</select>
|
||||||
<div class="chip-row scroll-x">
|
</label>
|
||||||
${[["all", "全部发布时间"], ["7", "近 7 天"], ["30", "近 30 天"], ["90", "近 90 天"]]
|
${scope === "up" ? `<label class="select-label">视频数
|
||||||
.map(([value, label]) => chip(label, String(within || "all") === value, "within", value)).join("")}
|
<select class="select" id="min-videos">
|
||||||
</div>
|
|
||||||
${scope === "video" && folders.length ? `<select class="select" id="folder-filter">
|
|
||||||
<option value="">全部收藏夹</option>
|
|
||||||
${folders.map((item) => `<option value="${item.media_id}"
|
|
||||||
${String(folder) === String(item.media_id) ? "selected" : ""}>${escapeHtml(item.title || item.year)}</option>`).join("")}
|
|
||||||
</select>` : ""}
|
|
||||||
${scope === "up" ? `<select class="select" id="min-videos">
|
|
||||||
<option value="1" ${String(minVideos) === "1" ? "selected" : ""}>全部 UP</option>
|
<option value="1" ${String(minVideos) === "1" ? "selected" : ""}>全部 UP</option>
|
||||||
<option value="2" ${String(minVideos) === "2" ? "selected" : ""}>≥ 2 条视频</option>
|
<option value="2" ${String(minVideos) === "2" ? "selected" : ""}>≥ 2 条</option>
|
||||||
<option value="5" ${String(minVideos) === "5" ? "selected" : ""}>≥ 5 条视频</option>
|
<option value="5" ${String(minVideos) === "5" ? "selected" : ""}>≥ 5 条</option>
|
||||||
</select>` : ""}
|
</select>
|
||||||
|
</label>` : ""}
|
||||||
</div>
|
</div>
|
||||||
${scope === "video" && (sort === "growth" || sort === "growth_rate") ? `
|
${scope === "video" && (sort === "growth" || sort === "growth_rate") ? `
|
||||||
<div class="chip-row growth-days">
|
<div class="chip-row growth-days">
|
||||||
@@ -150,19 +167,23 @@ function durationNote(data) {
|
|||||||
发布满 ${filter.maturity_days} 天、播放 ≥ ${filter.min_view} 的视频${shrink};时长缺失的视频不参与排序。</div>`;
|
发布满 ${filter.maturity_days} 天、播放 ≥ ${filter.min_view} 的视频${shrink};时长缺失的视频不参与排序。</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindControls(root, scope) {
|
function bindControls(root) {
|
||||||
root.querySelectorAll("[data-sort]").forEach((button) => button.addEventListener("click", () => {
|
root.querySelector("#rank-sort")?.addEventListener("change", (event) => {
|
||||||
replaceQuery({ sort: button.dataset.sort, page: null });
|
replaceQuery({ sort: event.target.value, page: null });
|
||||||
}));
|
});
|
||||||
root.querySelectorAll("[data-within]").forEach((button) => button.addEventListener("click", () => {
|
root.querySelector("#rank-time")?.addEventListener("change", (event) => {
|
||||||
replaceQuery({ within_days: button.dataset.within === "all" ? null : button.dataset.within, page: null });
|
const value = event.target.value;
|
||||||
}));
|
if (value.startsWith("year:")) {
|
||||||
|
replaceQuery({ year: value.slice(5), within_days: null, page: null });
|
||||||
|
} else if (value === "all") {
|
||||||
|
replaceQuery({ year: null, within_days: null, page: null });
|
||||||
|
} else {
|
||||||
|
replaceQuery({ year: null, within_days: value, page: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
root.querySelectorAll("[data-growth-days]").forEach((button) => button.addEventListener("click", () => {
|
root.querySelectorAll("[data-growth-days]").forEach((button) => button.addEventListener("click", () => {
|
||||||
replaceQuery({ growth_days: button.dataset.growthDays, page: null });
|
replaceQuery({ growth_days: button.dataset.growthDays, page: null });
|
||||||
}));
|
}));
|
||||||
root.querySelector("#folder-filter")?.addEventListener("change", (event) => {
|
|
||||||
replaceQuery({ folder: event.target.value || null, page: null });
|
|
||||||
});
|
|
||||||
root.querySelector("#min-videos")?.addEventListener("change", (event) => {
|
root.querySelector("#min-videos")?.addEventListener("change", (event) => {
|
||||||
replaceQuery({ min_videos: event.target.value, page: null });
|
replaceQuery({ min_videos: event.target.value, page: null });
|
||||||
});
|
});
|
||||||
@@ -179,23 +200,23 @@ function bindControls(root, scope) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function renderScope(root, { query }, scope) {
|
async function renderScope(root, { query }, scope) {
|
||||||
const defaultSort = "efficiency";
|
const sort = query.get("sort") || "efficiency";
|
||||||
const sort = query.get("sort") || defaultSort;
|
const within = query.get("within_days") || "";
|
||||||
const within = query.get("within_days") || "all";
|
const year = query.get("year") || "";
|
||||||
const growthDays = query.get("growth_days") || "7";
|
const growthDays = query.get("growth_days") || "7";
|
||||||
const q = query.get("q") || "";
|
const q = query.get("q") || "";
|
||||||
const folder = query.get("folder") || "";
|
|
||||||
const minVideos = query.get("min_videos") || "1";
|
const minVideos = query.get("min_videos") || "1";
|
||||||
const page = Math.max(1, Number(query.get("page") || 1));
|
const page = Math.max(1, Number(query.get("page") || 1));
|
||||||
|
|
||||||
const overview = await getOverview();
|
const overview = await getOverview();
|
||||||
const folders = overview.folders || [];
|
const years = overview.years || [];
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
sort,
|
sort,
|
||||||
page,
|
page,
|
||||||
page_size: 20,
|
page_size: 20,
|
||||||
within_days: within === "all" ? null : within,
|
within_days: within || null,
|
||||||
|
year: year || null,
|
||||||
growth_days: growthDays,
|
growth_days: growthDays,
|
||||||
q,
|
q,
|
||||||
};
|
};
|
||||||
@@ -203,7 +224,7 @@ async function renderScope(root, { query }, scope) {
|
|||||||
if (scope === "up") {
|
if (scope === "up") {
|
||||||
data = await api("/api/ups", { ...params, min_videos: minVideos });
|
data = await api("/api/ups", { ...params, min_videos: minVideos });
|
||||||
} else {
|
} else {
|
||||||
data = await api("/api/videos", { ...params, folder: folder || null });
|
data = await api("/api/videos", params);
|
||||||
}
|
}
|
||||||
|
|
||||||
const startRank = (data.page - 1) * data.page_size + 1;
|
const startRank = (data.page - 1) * data.page_size + 1;
|
||||||
@@ -214,7 +235,7 @@ async function renderScope(root, { query }, scope) {
|
|||||||
<header class="page-head">
|
<header class="page-head">
|
||||||
<h1>${title} <span class="page-sub">共 ${formatCount(data.total)} ${unit}</span></h1>
|
<h1>${title} <span class="page-sub">共 ${formatCount(data.total)} ${unit}</span></h1>
|
||||||
</header>
|
</header>
|
||||||
${controls({ scope, sort, within, growthDays, q, folder, minVideos, folders })}
|
${controls({ scope, sort, within, year, growthDays, q, minVideos, years })}
|
||||||
${durationNote(data)}
|
${durationNote(data)}
|
||||||
${data.available === false ? `<div class="notice-row warning">
|
${data.available === false ? `<div class="notice-row warning">
|
||||||
<strong>${sort.startsWith("growth") ? "增长数据尚不可用" : "榜单筛选后为空"}</strong>
|
<strong>${sort.startsWith("growth") ? "增长数据尚不可用" : "榜单筛选后为空"}</strong>
|
||||||
@@ -224,7 +245,7 @@ async function renderScope(root, { query }, scope) {
|
|||||||
${pager(data.page, data.pages)}
|
${pager(data.page, data.pages)}
|
||||||
</section>`;
|
</section>`;
|
||||||
|
|
||||||
bindControls(root, scope);
|
bindControls(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderVideos(root, context) {
|
export function renderVideos(root, context) {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ def test_overview():
|
|||||||
assert overview["fans"]["follower"] == 1020
|
assert overview["fans"]["follower"] == 1020
|
||||||
assert overview["fans"]["delta_1d"] == 10
|
assert overview["fans"]["delta_1d"] == 10
|
||||||
assert overview["preview"] is False
|
assert overview["preview"] is False
|
||||||
|
assert overview["years"] == [2026]
|
||||||
assert "snapshot" in overview["tasks"]
|
assert "snapshot" in overview["tasks"]
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@@ -419,6 +420,32 @@ def test_duration_efficiency_queries():
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_year_filter_and_published_sort():
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
db, q = setup(tmp)
|
||||||
|
try:
|
||||||
|
by_year = q.list_videos(sort="view", year=2026)
|
||||||
|
assert [item["aid"] for item in by_year["items"]] == [102, 101, 103]
|
||||||
|
assert by_year["year"] == 2026
|
||||||
|
|
||||||
|
none = q.list_videos(sort="view", year=2025)
|
||||||
|
assert none["items"] == []
|
||||||
|
assert none["total"] == 0
|
||||||
|
|
||||||
|
published = q.list_videos(sort="published")
|
||||||
|
assert [item["aid"] for item in published["items"]] == [103, 101, 102]
|
||||||
|
|
||||||
|
added = q.list_videos(sort="added")
|
||||||
|
assert added["total"] == 3
|
||||||
|
|
||||||
|
ups = q.list_ups(sort="efficiency", year=2026)
|
||||||
|
assert ups["year"] == 2026 and ups["total"] == 2
|
||||||
|
ups_none = q.list_ups(sort="views", year=2025)
|
||||||
|
assert ups_none["total"] == 0
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def test_about_payload():
|
def test_about_payload():
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
db, q = setup(tmp)
|
db, q = setup(tmp)
|
||||||
@@ -454,6 +481,7 @@ def main_test():
|
|||||||
test_up_queries()
|
test_up_queries()
|
||||||
test_query_cache_invalidation()
|
test_query_cache_invalidation()
|
||||||
test_duration_efficiency_queries()
|
test_duration_efficiency_queries()
|
||||||
|
test_year_filter_and_published_sort()
|
||||||
test_about_payload()
|
test_about_payload()
|
||||||
test_fans_custom_range_modes()
|
test_fans_custom_range_modes()
|
||||||
test_fans_candidates_system_and_export()
|
test_fans_candidates_system_and_export()
|
||||||
|
|||||||
@@ -111,6 +111,15 @@ def test_readonly_routes_and_static():
|
|||||||
items = json.loads(payload)["data"]["items"]
|
items = json.loads(payload)["data"]["items"]
|
||||||
assert items and items[0]["aid"] == 102
|
assert items and items[0]["aid"] == 102
|
||||||
|
|
||||||
|
status, _, payload = running.json("/api/videos?year=2026")
|
||||||
|
assert status == 200
|
||||||
|
assert payload["data"]["year"] == 2026
|
||||||
|
status, _, payload = running.json("/api/videos?year=1800")
|
||||||
|
assert status == 400
|
||||||
|
status, _, payload = running.json("/api/ups?year=2026")
|
||||||
|
assert status == 200
|
||||||
|
assert payload["data"]["year"] == 2026
|
||||||
|
|
||||||
status, _, payload = running.request("/api/ups")
|
status, _, payload = running.request("/api/ups")
|
||||||
assert status == 200
|
assert status == 200
|
||||||
assert json.loads(payload)["data"]["sort"] == "efficiency"
|
assert json.loads(payload)["data"]["sort"] == "efficiency"
|
||||||
|
|||||||
Reference in new issue
Block a user