feat: add PPAC candidate tab and status
Deploy production / deploy (push) Successful in 56s

This commit is contained in:
yuxuanhui
2026-09-13 12:57:38 +08:00
parent e256d6fef1
commit 8444e3055e
10 changed files with 319 additions and 33 deletions
@@ -0,0 +1,24 @@
# Alpha 管理候选PPAC
Type: task
Status: ready-for-agent
用户要求新增固定候选清单,并将对应检查结果显示为黄色“候选PPAC”Tag。
## 实现范围
- 仅有一项 Alpha FAIL 且名称为 PURE_POWER_POOL_THEME 时,检查结果派生为 PPAC_CANDIDATE;REGULAR_SUBMISSION 沿用既有排除口径,WARNING/PENDING 不计失败。
- 固定“候选PPAC”Tab 通过 ppac_candidate=true 在数据库分页前筛选未提交候选;叠加筛选、分页、导出、保存/恢复视图及 AI 页面上下文保持一致。
- 列表和详情显示黄色候选状态,保留原始 FAIL 检查证据与本地研究记录。候选身份不表示当前可正式提交。
- 迁移 0022 复用已有 check_type 索引,仅重分类匹配的历史缓存。同步和主动检查沿用 snapshot_columns 自动刷新分类。
## 验证
分类边界、组合筛选/分页/导出、同步与主动检查后的状态迁移、隔离历史迁移、后端静态检查、前端构建和浏览器固定 Tab/黄色 Tag/保存恢复/窄屏验证。
## Comments
已完成本地实现与验证:95 项后端回归通过,包含分类边界、真实业务执行器对模拟 /check 的状态刷新、筛选/分页/导出/保存视图、503 条历史缓存的升级/降级/再升级及迁移 schema check;Ruff、TypeScript 与生产构建通过。
原有 Alpha 管理、提交受阻和本地相关性筛选 3 项浏览器回归通过。另在隔离临时库使用 56 条合成 PPAC Alpha 验证:固定 Tab、列表/详情黄色 Tag、原始 FAIL 保留、跨页勾选、搜索后回第一页及清空选择、保存/恢复视图、空结果和重置保持候选范围。1920px 桌面与 720px 窄屏完成截图检查,行高为 40px,窄屏分页底部 887px 位于 900px 视口内,页面未横向溢出,表体可独立横向滚动。
截图位于忽略目录 output/playwright/ppac-candidate-{desktop,detail,narrow}.png。构建保留已有 lottie eval 和大包提示;浏览器仅有登录前 auth/me 的 401 与 favicon 404。未部署、未迁移实际业务数据库、未请求真实平台。
+13 -4
View File
@@ -31,16 +31,20 @@ def snapshot_columns(settings, metrics, checks, *, checked=False):
Sync snapshots with no failures are PRE_CHECK; a completed explicit /check Sync snapshots with no failures are PRE_CHECK; a completed explicit /check
with no failures is PASS. WARNING/PENDING do not count as failures, matching with no failures is PASS. WARNING/PENDING do not count as failures, matching
the legacy workflow. Empty, malformed or unknown results remain PENDING. the legacy workflow. Empty, malformed or unknown results remain PENDING.
No submission eligibility or activity eligibility is inferred here. A sole PURE_POWER_POOL_THEME failure is a PPAC candidate, not confirmation
of current submission or activity eligibility. Raw failures stay available.
""" """
settings = settings if isinstance(settings, dict) else {} settings = settings if isinstance(settings, dict) else {}
metrics = metrics if isinstance(metrics, dict) else {} metrics = metrics if isinstance(metrics, dict) else {}
blocked = submission_limits(checks)["status"] == "blocked" blocked = submission_limits(checks)["status"] == "blocked"
checks, _ = split_checks(checks) checks, _ = split_checks(checks)
valid = [check for check in checks if isinstance(check, dict)] valid = [check for check in checks if isinstance(check, dict)]
failures = len(failed_checks(checks)) failed_names = failed_checks(checks)
failures = len(failed_names)
by_name = {check["name"]: check for check in valid if isinstance(check.get("name"), str)} by_name = {check["name"]: check for check in valid if isinstance(check.get("name"), str)}
if failures: if failed_names == ["PURE_POWER_POOL_THEME"]:
check_type = "PPAC_CANDIDATE"
elif failures:
check_type = "FAIL_1" if failures == 1 else "FAIL_2" check_type = "FAIL_1" if failures == 1 else "FAIL_2"
elif not checks or len(valid) != len(checks) or any(check_result(check) not in ("PASS", "WARNING", "PENDING") for check in valid): elif not checks or len(valid) != len(checks) or any(check_result(check) not in ("PASS", "WARNING", "PENDING") for check in valid):
check_type = "PENDING" check_type = "PENDING"
@@ -75,7 +79,7 @@ def check_summary(checks, *, check_type):
"check_type": check_type, "check_type": check_type,
"failed_checks": failed_checks(checks), "failed_checks": failed_checks(checks),
"submission_limits": submission_limits(checks), "submission_limits": submission_limits(checks),
"meaning": "PRE_CHECK 为同步无失败项;PASS 为主动检查完成且无失败项。PENDING/WARNING 不算失败,不代表全部检查项 PASS 或当前可提交", "meaning": "PRE_CHECK 为同步无失败项;PASS 为主动检查完成且无失败项;PPAC_CANDIDATE 为唯一失败项是 PURE_POWER_POOL_THEME 的候选。PENDING/WARNING 不算失败,不代表全部检查项 PASS 或当前可提交",
} }
@@ -199,6 +203,11 @@ def list_statement(filters):
query = query.where(Alpha.submission_blocked == filters.submission_blocked) query = query.where(Alpha.submission_blocked == filters.submission_blocked)
if filters.submission_blocked: if filters.submission_blocked:
query = query.where(submission_condition("UNSUBMITTED")) query = query.where(submission_condition("UNSUBMITTED"))
if filters.ppac_candidate is not None:
candidate = Alpha.check_type == "PPAC_CANDIDATE"
query = query.where(candidate if filters.ppac_candidate else ~candidate)
if filters.ppac_candidate:
query = query.where(submission_condition("UNSUBMITTED"))
if (filters.local_correlation_status is not None or filters.local_correlation_min is not None if (filters.local_correlation_status is not None or filters.local_correlation_min is not None
or filters.local_correlation_max is not None): or filters.local_correlation_max is not None):
# One cache row per Alpha keeps totals/export stable; stale overrides the displayed status. # One cache row per Alpha keeps totals/export stable; stale overrides the displayed status.
+2 -1
View File
@@ -9,7 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
ResearchState = Literal["inbox", "candidate", "optimizing", "archived"] ResearchState = Literal["inbox", "candidate", "optimizing", "archived"]
Submission = Literal["UNSUBMITTED", "SUBMITTED"] Submission = Literal["UNSUBMITTED", "SUBMITTED"]
CheckType = Literal["PENDING", "PRE_CHECK", "PASS", "FAIL_1", "FAIL_2"] CheckType = Literal["PENDING", "PRE_CHECK", "PASS", "PPAC_CANDIDATE", "FAIL_1", "FAIL_2"]
SortField = Literal[ SortField = Literal[
"id", "id",
"name", "name",
@@ -74,6 +74,7 @@ class AlphaFilters(Contract):
local_correlation_min: float | None = Field(default=None, ge=-1, le=1) local_correlation_min: float | None = Field(default=None, ge=-1, le=1)
local_correlation_max: float | None = Field(default=None, ge=-1, le=1) local_correlation_max: float | None = Field(default=None, ge=-1, le=1)
submission_blocked: bool | None = None submission_blocked: bool | None = None
ppac_candidate: bool | None = None
submission: Submission | None = None submission: Submission | None = None
source: str | None = Field(default=None, max_length=100) source: str | None = Field(default=None, max_length=100)
source_reference: str | None = Field(default=None, max_length=200) source_reference: str | None = Field(default=None, max_length=200)
@@ -0,0 +1,41 @@
"""Classify snapshots whose sole Alpha failure is the unopened PPAC theme."""
import sqlalchemy as sa
from alembic import op
revision = "0022"
down_revision = "0021"
branch_labels = None
depends_on = None
def upgrade():
# Reuse the indexed check_type column; preserve all raw evidence and check stages.
table = sa.table("alphas", sa.column("id", sa.String()), sa.column("checks", sa.JSON()),
sa.column("check_type", sa.String(20)))
connection = op.get_bind()
last_id = None
while True:
query = sa.select(table.c.id, table.c.checks).order_by(table.c.id).limit(500)
if last_id is not None:
query = query.where(table.c.id > last_id)
rows = connection.execute(query).mappings().all()
if not rows:
break
candidates = []
for row in rows:
# Freeze the current interpretation instead of importing application code.
checks = row["checks"] if isinstance(row["checks"], list) else []
failures = [check for check in checks if isinstance(check, dict)
and check.get("name") != "REGULAR_SUBMISSION"
and isinstance(check.get("result"), str) and check["result"].upper() == "FAIL"]
if len(failures) == 1 and failures[0].get("name") == "PURE_POWER_POOL_THEME":
candidates.append(row["id"])
if candidates:
connection.execute(table.update().where(table.c.id.in_(candidates)).values(check_type="PPAC_CANDIDATE"))
last_id = rows[-1]["id"]
def downgrade():
table = sa.table("alphas", sa.column("check_type", sa.String(20)))
op.execute(table.update().where(table.c.check_type == "PPAC_CANDIDATE").values(check_type="FAIL_1"))
+108
View File
@@ -0,0 +1,108 @@
"""PPAC candidates retain their theme failure and wait for platform eligibility."""
import copy
import csv
import io
import pytest
from app.alphas import check_summary, snapshot_columns, upsert_alpha
from tests import test_submission
from tests.conftest import alpha
THEME = {"name": "PURE_POWER_POOL_THEME", "result": "FAIL"}
LIMIT = {"name": "REGULAR_SUBMISSION", "result": "FAIL"}
OTHER = {"name": "LOW_SHARPE", "result": "FAIL"}
@pytest.mark.parametrize("checked", [False, True])
@pytest.mark.parametrize("checks,expected", [
([THEME], "PPAC_CANDIDATE"),
([{**THEME, "result": "fail"}], "PPAC_CANDIDATE"),
([THEME, LIMIT, {"name": "MATCHES_THEMES", "result": "WARNING"},
{"name": "PROD_CORRELATION", "result": "PENDING"}], "PPAC_CANDIDATE"),
([THEME, OTHER], "FAIL_2"),
([THEME, THEME], "FAIL_2"),
([THEME, {"result": "FAIL"}], "FAIL_2"),
([OTHER], "FAIL_1"),
([{**THEME, "result": "PASS"}, OTHER], "FAIL_1"),
([{**THEME, "result": False}], "PENDING"),
([{**THEME, "value": False}], "PPAC_CANDIDATE"),
([], "PENDING"),
([{}], "PENDING"),
])
def test_only_a_single_explicit_theme_failure_is_a_candidate(checks, expected, checked):
original = copy.deepcopy(checks)
columns = snapshot_columns({}, {}, checks, checked=checked)
assert columns["check_type"] == expected
assert checks == original
if expected == "PPAC_CANDIDATE":
assert check_summary(checks, check_type=expected)["failed_checks"] == ["PURE_POWER_POOL_THEME"]
async def test_ppac_scope_filters_before_pagination_and_export(app, logged_in):
async with app.state.sessions.begin() as db:
for name, checks, status, region, hidden in [
("candidate1", [THEME], "UNSUBMITTED", "USA", False),
("candidate2", [THEME, LIMIT], "UNSUBMITTED", "USA", True),
("other_region", [THEME], "UNSUBMITTED", "CHN", False),
("submitted", [THEME], "ACTIVE", "USA", False),
("missing_status", [THEME], None, "USA", False),
("two_failures", [THEME, OTHER], "UNSUBMITTED", "USA", False),
("other_failure", [OTHER], "UNSUBMITTED", "USA", False),
("passed", [{**THEME, "result": "PASS"}], "UNSUBMITTED", "USA", False),
("missing", [], "UNSUBMITTED", "USA", False),
]:
await upsert_alpha(db, alpha(name, status=status, hidden=hidden, settings={"region": region},
**{"is": {"checks": checks}}))
query = "ppac_candidate=true&region=USA&sort=id&direction=asc&limit=1&offset=1"
response = await logged_in.get(f"/api/v1/alphas?{query}")
assert response.status_code == 200, response.text
result = response.json()
assert result["total"] == 2
assert [row["id"] for row in result["items"]] == ["candidate2"]
assert result["items"][0]["check_type"] == "PPAC_CANDIDATE"
assert result["items"][0]["failed_checks"] == ["PURE_POWER_POOL_THEME"]
export = await logged_in.get(f"/api/v1/alphas/export?{query}")
rows = list(csv.DictReader(io.StringIO(export.text.lstrip("\ufeff"))))
assert [row["id"] for row in rows] == ["candidate1", "candidate2"]
assert {row["check_type"] for row in rows} == {"PPAC_CANDIDATE"}
assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true&submission=SUBMITTED")).json()["total"] == 0
assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true&check_type=FAIL_1")).json()["total"] == 0
assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true&submission_blocked=true")).json()["total"] == 1
assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true&q=candidate1")).json()["total"] == 1
assert (await logged_in.get("/api/v1/alphas?check_type=PPAC_CANDIDATE&submission=UNSUBMITTED")).json()["total"] == 3
assert (await logged_in.get("/api/v1/alphas?ppac_candidate=false")).json()["total"] == 4
saved = await logged_in.post("/api/v1/research/assets", json={
"kind": "view", "content": {"name": "候选PPAC", "filters": {
"submission": "UNSUBMITTED", "ppac_candidate": True, "region": "USA",
}, "columns": ["name", "check_type"]},
})
assert saved.status_code in (200, 201), saved.text
assert saved.json()["content"]["filters"]["ppac_candidate"] is True
async def test_check_and_sync_refresh_candidate_status_without_losing_evidence(app, logged_in, monkeypatch):
await test_submission.setup(app, alpha(**{"is": {"checks": [OTHER]}}))
for checks, expected in [([THEME, LIMIT], "PPAC_CANDIDATE"), ([{**THEME, "result": "PASS"}], "PASS")]:
monkeypatch.setattr(test_submission, "CHECKS", checks)
response = await test_submission.enqueue(logged_in)
assert response.status_code == 202, response.text
await app.state.runner.execute(response.json()["id"])
state = (await logged_in.get("/api/v1/alphas/alpha1/submission")).json()
assert state["job"]["status"] == "completed"
assert state["check_summary"]["check_type"] == expected
detail = (await logged_in.get("/api/v1/alphas/alpha1")).json()
assert detail["checks"] == checks
assert detail["check_type"] == expected
assert detail["research"]["note"] == "preserve local research"
assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true")).json()["total"] == (expected == "PPAC_CANDIDATE")
for checks, expected in [([THEME], "PPAC_CANDIDATE"), ([THEME, OTHER], "FAIL_2")]:
async with app.state.sessions.begin() as db:
await upsert_alpha(db, alpha(**{"is": {"checks": checks}}))
detail = (await logged_in.get("/api/v1/alphas/alpha1")).json()
assert detail["check_type"] == expected
assert detail["checks"] == checks
assert detail["research"]["note"] == "preserve local research"
assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true")).json()["total"] == (expected == "PPAC_CANDIDATE")
@@ -0,0 +1,55 @@
"""Historical PPAC classification changes only matching cached check types."""
from datetime import datetime, timezone
from pathlib import Path
import sqlalchemy as sa
from alembic import command
from alembic.config import Config
from cryptography.fernet import Fernet
def test_ppac_backfill_preserves_evidence_across_batches_and_downgrade(tmp_path, monkeypatch):
database = tmp_path / "ppac.db"
monkeypatch.setenv("DATABASE_URL", f"sqlite+aiosqlite:///{database}")
monkeypatch.setenv("ADMIN_PASSWORD", "migration-test-only")
monkeypatch.setenv("ENCRYPTION_KEY", Fernet.generate_key().decode())
monkeypatch.setenv("WQ_EMAIL", "")
monkeypatch.setenv("WQ_PASSWORD", "")
root = Path(__file__).resolve().parents[1]
config = Config(str(root / "alembic.ini"))
config.set_main_option("script_location", str(root / "migrations"))
command.upgrade(config, "0021")
engine = sa.create_engine(f"sqlite:///{database}")
alphas = sa.Table("alphas", sa.MetaData(), autoload_with=engine)
theme = {"name": "PURE_POWER_POOL_THEME", "result": "FAIL"}
other = {"name": "LOW_SHARPE", "result": "FAIL"}
patterns = [
([theme], "FAIL_1", "PPAC_CANDIDATE"),
([{**theme, "result": "fail"}, {"name": "REGULAR_SUBMISSION", "result": "FAIL"}], "FAIL_1", "PPAC_CANDIDATE"),
([theme, other], "FAIL_2", "FAIL_2"),
([theme, theme], "FAIL_2", "FAIL_2"),
([other], "FAIL_1", "FAIL_1"),
([{**theme, "result": "PASS"}], "PASS", "PASS"),
([{**theme, "result": "WARNING"}], "PRE_CHECK", "PRE_CHECK"),
([{}], "PENDING", "PENDING"),
(None, "PENDING", "PENDING"),
]
with engine.begin() as db:
db.execute(alphas.insert(), [
{"id": f"ppac{i:04}", "status": "UNSUBMITTED", "hidden": False, "settings": {}, "os_metrics": {},
"is_metrics": {"checks": patterns[i % len(patterns)][0]}, "checks": patterns[i % len(patterns)][0],
"check_type": patterns[i % len(patterns)][1], "synced_at": datetime(2026, 9, 13, tzinfo=timezone.utc),
"raw": {"is": {"checks": patterns[i % len(patterns)][0]}}}
for i in range(503)
])
original = db.execute(sa.select(alphas).order_by(alphas.c.id)).mappings().all()
for target, position in [("0022", 2), ("0021", 1), ("0022", 2)]:
(command.upgrade if target == "0022" else command.downgrade)(config, target)
with engine.connect() as db:
rows = db.execute(sa.select(alphas).order_by(alphas.c.id)).mappings().all()
assert len(rows) == 503
for i, row in enumerate(rows):
assert dict(row) == {**original[i], "check_type": patterns[i % len(patterns)][position]}
command.check(config)
engine.dispose()
+11
View File
@@ -230,6 +230,11 @@ export function AlphaDetail({
<Tag>{detail.alpha_type ?? "类型未提供"}</Tag> <Tag>{detail.alpha_type ?? "类型未提供"}</Tag>
<Tag>{detail.language ?? "语言未提供"}</Tag> <Tag>{detail.language ?? "语言未提供"}</Tag>
<Tag>{detail.status ?? "状态未提供"}</Tag> <Tag>{detail.status ?? "状态未提供"}</Tag>
{detail.check_type === "PPAC_CANDIDATE" && (
<Tag color="yellow" size="small" type="light">
候选PPAC
</Tag>
)}
</div> </div>
</div> </div>
<a <a
@@ -240,6 +245,12 @@ export function AlphaDetail({
<Button>在 BRAIN 中打开 ↗</Button> <Button>在 BRAIN 中打开 ↗</Button>
</a> </a>
</div> </div>
{detail.check_type === "PPAC_CANDIDATE" && (
<p className="muted">
唯一失败项为 PURE_POWER_POOL_THEME,等待平台开放 PPAC
主题后可重新检查提交资格。
</p>
)}
{detail.alpha_type === "SUPER" && ( {detail.alpha_type === "SUPER" && (
<Button <Button
onClick={() => onClick={() =>
+44 -19
View File
@@ -99,6 +99,7 @@ const checkLabels = {
PENDING: "待检查", PENDING: "待检查",
PRE_CHECK: "预检通过", PRE_CHECK: "预检通过",
PASS: "检查通过", PASS: "检查通过",
PPAC_CANDIDATE: "候选PPAC",
FAIL_1: "FAIL=1", FAIL_1: "FAIL=1",
FAIL_2: "FAIL≥2", FAIL_2: "FAIL≥2",
}; };
@@ -205,6 +206,7 @@ export function AlphaPage({
const [filterOpen, setFilterOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false);
const [selectedViewId, setSelectedViewId] = useState<string | null>(null); const [selectedViewId, setSelectedViewId] = useState<string | null>(null);
const [submissionBlocked, setSubmissionBlocked] = useState(false); const [submissionBlocked, setSubmissionBlocked] = useState(false);
const [ppacCandidate, setPpacCandidate] = useState(false);
const [submission, setSubmission] = useState<Submission>("UNSUBMITTED"); const [submission, setSubmission] = useState<Submission>("UNSUBMITTED");
const [syncingScope, setSyncingScope] = useState<Submission | null>(null); const [syncingScope, setSyncingScope] = useState<Submission | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -274,6 +276,7 @@ export function AlphaPage({
management_scope: managementScope, management_scope: managementScope,
submission, submission,
submission_blocked: submissionBlocked || undefined, submission_blocked: submissionBlocked || undefined,
ppac_candidate: ppacCandidate || undefined,
sort, sort,
direction, direction,
limit: pageSize, limit: pageSize,
@@ -283,6 +286,7 @@ export function AlphaPage({
filters, filters,
submission, submission,
submissionBlocked, submissionBlocked,
ppacCandidate,
sort, sort,
direction, direction,
pageSize, pageSize,
@@ -320,6 +324,7 @@ export function AlphaPage({
offset: _offset, offset: _offset,
submission: nextSubmission, submission: nextSubmission,
submission_blocked: nextBlocked, submission_blocked: nextBlocked,
ppac_candidate: nextPpacCandidate,
...values ...values
} = action.filters; } = action.filters;
const next = Object.fromEntries( const next = Object.fromEntries(
@@ -334,6 +339,9 @@ export function AlphaPage({
if (nextSubmission === "SUBMITTED" || nextSubmission === "UNSUBMITTED") if (nextSubmission === "SUBMITTED" || nextSubmission === "UNSUBMITTED")
setSubmission(nextSubmission); setSubmission(nextSubmission);
setSubmissionBlocked(nextBlocked === true || nextBlocked === "true"); setSubmissionBlocked(nextBlocked === true || nextBlocked === "true");
setPpacCandidate(
nextPpacCandidate === true || nextPpacCandidate === "true",
);
if (nextSort) setSort(String(nextSort)); if (nextSort) setSort(String(nextSort));
if (nextDirection) setDirection(String(nextDirection)); if (nextDirection) setDirection(String(nextDirection));
} }
@@ -367,7 +375,7 @@ export function AlphaPage({
useEffect(() => { useEffect(() => {
setSelected([]); setSelected([]);
selectionVersions.current = {}; selectionVersions.current = {};
}, [filters, submission, submissionBlocked, managementScope]); }, [filters, submission, submissionBlocked, ppacCandidate, managementScope]);
useEffect(() => { useEffect(() => {
tablePanel.current?.querySelector(".semi-table-body")?.scrollTo({ top: 0 }); tablePanel.current?.querySelector(".semi-table-body")?.scrollTo({ top: 0 });
}, [query]); }, [query]);
@@ -394,6 +402,7 @@ export function AlphaPage({
setDirection("desc"); setDirection("desc");
setSubmission(value === "SUBMITTED" ? "SUBMITTED" : "UNSUBMITTED"); setSubmission(value === "SUBMITTED" ? "SUBMITTED" : "UNSUBMITTED");
setSubmissionBlocked(value === "SUBMISSION_BLOCKED"); setSubmissionBlocked(value === "SUBMISSION_BLOCKED");
setPpacCandidate(value === "PPAC_CANDIDATE");
setDraft({}); setDraft({});
setFilters({}); setFilters({});
setPage(1); setPage(1);
@@ -582,14 +591,18 @@ export function AlphaPage({
width: 125, width: 125,
render: (_, row) => ( render: (_, row) => (
<Tag <Tag
size="small"
type="light"
color={ color={
row!.check_type?.startsWith("FAIL") row!.check_type === "PPAC_CANDIDATE"
? "red" ? "yellow"
: row!.check_type === "PASS" : row!.check_type?.startsWith("FAIL")
? "green" ? "red"
: row!.check_type === "PRE_CHECK" : row!.check_type === "PASS"
? "blue" ? "green"
: "grey" : row!.check_type === "PRE_CHECK"
? "blue"
: "grey"
} }
> >
{checkLabels[row!.check_type] || "待检查"} {checkLabels[row!.check_type] || "待检查"}
@@ -1010,6 +1023,7 @@ export function AlphaPage({
managementScope={managementScope} managementScope={managementScope}
submission={submission} submission={submission}
submissionBlocked={submissionBlocked} submissionBlocked={submissionBlocked}
ppacCandidate={ppacCandidate}
selectedId={selectedViewId} selectedId={selectedViewId}
onSelect={setSelectedViewId} onSelect={setSelectedViewId}
onSubmissionChange={changeSubmission} onSubmissionChange={changeSubmission}
@@ -1019,6 +1033,7 @@ export function AlphaPage({
...filters, ...filters,
submission, submission,
submission_blocked: submissionBlocked || undefined, submission_blocked: submissionBlocked || undefined,
ppac_candidate: ppacCandidate || undefined,
sort, sort,
direction, direction,
limit: pageSize, limit: pageSize,
@@ -1029,6 +1044,7 @@ export function AlphaPage({
const { const {
submission: savedSubmission, submission: savedSubmission,
submission_blocked: savedBlocked, submission_blocked: savedBlocked,
ppac_candidate: savedPpacCandidate,
sort: savedSort, sort: savedSort,
direction: savedDirection, direction: savedDirection,
limit, limit,
@@ -1050,6 +1066,9 @@ export function AlphaPage({
setSubmissionBlocked( setSubmissionBlocked(
savedBlocked === true || savedBlocked === "true", savedBlocked === true || savedBlocked === "true",
); );
setPpacCandidate(
savedPpacCandidate === true || savedPpacCandidate === "true",
);
setSort(String(savedSort || "date_created")); setSort(String(savedSort || "date_created"));
setDirection(String(savedDirection || "desc")); setDirection(String(savedDirection || "desc"));
if (typeof limit === "number") setPageSize(limit); if (typeof limit === "number") setPageSize(limit);
@@ -1336,14 +1355,18 @@ export function AlphaPage({
<Empty <Empty
className="alpha-empty" className="alpha-empty"
title={ title={
activeFilterCount ppacCandidate
? "没有符合条件的 Alpha" ? "没有符合条件的候选PPAC"
: "从第一个 Alpha 开始" : activeFilterCount
? "没有符合条件的 Alpha"
: "从第一个 Alpha 开始"
} }
description={ description={
activeFilterCount ppacCandidate
? "调整筛选条件,探索更多信号。" ? "仅收录未提交且唯一失败项为 PURE_POWER_POOL_THEME 的 Alpha,等待平台开放 PPAC 主题。"
: "连接 WorldQuant 并同步数据,或通过 Alpha ID 导入已有研究。" : activeFilterCount
? "调整筛选条件,探索更多信号。"
: "连接 WorldQuant 并同步数据,或通过 Alpha ID 导入已有研究。"
} }
/> />
} }
@@ -1351,11 +1374,13 @@ export function AlphaPage({
<footer className="table-pagination" aria-label="Alpha 分页"> <footer className="table-pagination" aria-label="Alpha 分页">
<div className="alpha-table-meta"> <div className="alpha-table-meta">
<span className="muted"> <span className="muted">
{submissionBlocked {ppacCandidate
? "提交受阻" ? "候选PPAC"
: submission === "UNSUBMITTED" : submissionBlocked
? "待提交" ? "提交受阻"
: "已提交"}{" "} : submission === "UNSUBMITTED"
? "待提交"
: "已提交"}{" "}
Alpha{" "} Alpha{" "}
<span className="count-pill">{data.total.toLocaleString()}</span>{" "} <span className="count-pill">{data.total.toLocaleString()}</span>{" "}
条 条
+14 -8
View File
@@ -49,6 +49,7 @@ export function SavedViews({
columns, columns,
submission, submission,
submissionBlocked, submissionBlocked,
ppacCandidate,
selectedId, selectedId,
onSelect, onSelect,
onSubmissionChange, onSubmissionChange,
@@ -61,6 +62,7 @@ export function SavedViews({
columns: string[]; columns: string[];
submission: Submission; submission: Submission;
submissionBlocked: boolean; submissionBlocked: boolean;
ppacCandidate: boolean;
selectedId: string | null; selectedId: string | null;
onSelect: (id: string | null) => void; onSelect: (id: string | null) => void;
onSubmissionChange: (submission: string) => void; onSubmissionChange: (submission: string) => void;
@@ -75,6 +77,11 @@ export function SavedViews({
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [revision, setRevision] = useState(0); const [revision, setRevision] = useState(0);
const current = items.find((item) => item.id === selectedId); const current = items.find((item) => item.id === selectedId);
const fixedTab = ppacCandidate
? "PPAC_CANDIDATE"
: submissionBlocked
? "SUBMISSION_BLOCKED"
: submission;
const modified = const modified =
current && current &&
(filterSignature(filters) !== filterSignature(current.content.filters) || (filterSignature(filters) !== filterSignature(current.content.filters) ||
@@ -165,10 +172,7 @@ export function SavedViews({
setItems((previous) => setItems((previous) =>
previous.filter((item) => item.id !== deleting.id), previous.filter((item) => item.id !== deleting.id),
); );
if (selectedId === deleting.id) if (selectedId === deleting.id) onSubmissionChange(fixedTab);
onSubmissionChange(
submissionBlocked ? "SUBMISSION_BLOCKED" : submission,
);
setDeleting(null); setDeleting(null);
Toast.success("视图已删除"); Toast.success("视图已删除");
} catch (error) { } catch (error) {
@@ -185,10 +189,7 @@ export function SavedViews({
type="card" type="card"
collapsible="auto" collapsible="auto"
tabPaneMotion={false} tabPaneMotion={false}
activeKey={ activeKey={selectedId ?? fixedTab}
selectedId ??
(submissionBlocked ? "SUBMISSION_BLOCKED" : submission)
}
onChange={(key) => { onChange={(key) => {
const view = items.find((item) => item.id === key); const view = items.find((item) => item.id === key);
if (view) { if (view) {
@@ -212,6 +213,11 @@ export function SavedViews({
tab: "提交受阻", tab: "提交受阻",
icon: <IconList aria-hidden="true" />, icon: <IconList aria-hidden="true" />,
}, },
{
itemKey: "PPAC_CANDIDATE",
tab: "候选PPAC",
icon: <IconList aria-hidden="true" />,
},
...items.map((view) => ({ ...items.map((view) => ({
itemKey: view.id, itemKey: view.id,
icon: <IconList aria-hidden="true" />, icon: <IconList aria-hidden="true" />,
+7 -1
View File
@@ -58,7 +58,13 @@ export type Alpha = {
date_created: string | null; date_created: string | null;
date_submitted: string | null; date_submitted: string | null;
synced_at: string; synced_at: string;
check_type: "PENDING" | "PRE_CHECK" | "PASS" | "FAIL_1" | "FAIL_2"; check_type:
| "PENDING"
| "PRE_CHECK"
| "PASS"
| "PPAC_CANDIDATE"
| "FAIL_1"
| "FAIL_2";
failed_checks: string[]; failed_checks: string[];
neutralization: string | null; neutralization: string | null;
sub_universe_sharpe: number | null; sub_universe_sharpe: number | null;