feat(backtest): 买卖点理由(用数据说话)+ 因子曲线 + 曲线新页面放大
用户要求:「所有买卖点详细说明买卖理由,用数据说话」「回测图上增加因子相关曲线
(买卖依据是股息率,就加股息率曲线)」「所有曲线能弹出新页面放大」。
一、买卖理由(后端产出结构化数据,前端只展示)
- 新增 `quant/trade_reasons.py`:封闭词表 + 文案构造器,组合引擎与单策略引擎共用,
避免两个引擎对同一件事写出两种说法。理由里带**引擎当时的真实数字**:
综合分名次/候选数/综合分/各因子原始值/持有交易日/预算与最低佣金/涨停比值等。
- 买入:按名次建仓、顺延成交、涨停未买、停牌未买、现金不足、不足最低佣金;
卖出:跌出 TopN(含第几名掉出)、被股票池过滤(与「跌出 TopN」分开写)、
超 Tmax 强制了结、Tmin 保护暂留、停牌/跌停顺延。
- `ActionRecord.reason` 覆盖**成交与未成交**全部买卖点(原 `reject_reason` 保留不动,
老归档仍可读);`Trade.entry_reason / exit_reason` 跟着成交记录走。
- 名次来自调仓日完整排名(新增 `_ranked_by_day`),拿不到名次时如实写「未给出名次」,
绝不编造一个名次填进去。
- 未成交明细不再只写执行层原因:把「为什么选中它、当时各因子多少」一并给出。
二、因子曲线
- `FactorCurve`:每个策略因子一条曲线,值为**当日持仓按市值加权平均的原始值**
(不做 z-score、不按方向取反,空仓日不落点、不插值、不用 0 填充),并带
label/direction/unit 供界面说明口径;`FactorDef/FactorTemplate` 新增 `unit`
(股息率 %、量比/接近新高 倍数、动量等 小数),11 个内置因子实例已逐一核对。
- 归档体积预算照旧按整包计量,无需改迁移。
三、界面
- 结果页新增「买卖说明」区块:全部买卖点 + 理由 + 数字标签,支持方向/成交状态/关键字
筛选与日期排序;成交明细表加「为什么买 / 为什么卖」两列;新增「因子曲线」区块,
每条曲线标出组合成交日,直接对照「买卖发生在什么水平」。
- 「新页面放大」:每条曲线(净值/回撤/因子/个股/月度)都能开 `/charts/{归档id}?s=...`
整页看大图;放大页是 Server Component,数据从归档直出,URL 可分享且与归档一致。
未归档的结果如实说明「未归档,无法放大」,不给坏链接。
- 数字格式与后端 `f"{v:.4f}"` 同规则(四舍六入五成双):修掉 0.03125 在理由原文里
显示 0.0312、旁边标签显示 0.0313 的不一致(17 组边界值与 Python 逐一比对一致)。
- `/factors/compose` 结果区改用同一个 `BacktestResultView`,两处口径不会再漂移。
验证:
- 新增 `tests/test_trade_reasons.py` 8 条(买入数字、跌出 TopN 名次、不在候选池、
Tmax、Tmin 暂留、涨停未成交、因子曲线加权值、空仓不落点);后端 510 条全过,ruff clean。
- 真实数据端到端:`/api/combos/run` 6 个月高股息组合(EXP-8EA2819B)13 个买卖点
100% 带理由与数字,因子曲线 dividend_yield 117 点、单位 %;
`scripts/verify_backtest_page_contract.py`(4 年、301 个买卖点、140 笔成交)扩展断言
理由词表/名次/因子值/曲线单调性后通过。
- 浏览器实测:归档详情页与放大页 `/charts/...?s=factor:dividend_yield` 等 5 种曲线
全部 200 渲染,截图确认表格与曲线数值正确。
This commit is contained in:
@@ -127,6 +127,9 @@ function BacktestInner() {
|
||||
const [draft, setDraft] = useState<Draft>(emptyDraft(range));
|
||||
const [savedComboId, setSavedComboId] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<BacktestResult | null>(null);
|
||||
// 本次结果的归档 id:结果区据此提供「新页面放大」(放大页从归档读同一份数据)。
|
||||
// 同步/未归档的结果没有 id,放大入口会如实说明原因而不是给个坏链接。
|
||||
const [resultArchiveId, setResultArchiveId] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [runningComboId, setRunningComboId] = useState<string | null>(null);
|
||||
const [jobId, setJobId] = useState("");
|
||||
@@ -239,6 +242,7 @@ function BacktestInner() {
|
||||
setNotice("");
|
||||
setJobId("");
|
||||
setResult(null);
|
||||
setResultArchiveId(null);
|
||||
try {
|
||||
const { job_id } = await submit();
|
||||
setJobId(job_id);
|
||||
@@ -250,6 +254,7 @@ function BacktestInner() {
|
||||
if (out.status === "success" && out.result) {
|
||||
setResult(out.result);
|
||||
const exp = out.experimentId ?? null;
|
||||
setResultArchiveId(exp);
|
||||
setNotice(exp ? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。` : "回测完成,已自动归档。");
|
||||
} else {
|
||||
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
|
||||
@@ -649,7 +654,13 @@ function BacktestInner() {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{result ? <BacktestResultView result={result} name={draft.name || "回测组合"} /> : null}
|
||||
{result ? (
|
||||
<BacktestResultView
|
||||
result={result}
|
||||
name={draft.name || "回测组合"}
|
||||
archive={resultArchiveId ? { id: resultArchiveId } : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 曲线放大页(`/charts/{归档id}?s={曲线}`)—— 「所有曲线都能弹出新页面看大的」。
|
||||
*
|
||||
* 设计取舍:
|
||||
* - **Server Component**:数据从归档直出(URL 即快照地址,刷新/分享都还原同一张图);
|
||||
* 曲线切换用 URL 参数(`?s=`),每个切换按钮就是一个 `<Link>`,不需要客户端状态。
|
||||
* - **一页一曲线、尽可能大**:放大页只干一件事 —— 把一条曲线画大。因此高度直接给足
|
||||
* (`CHART_HEIGHT`),并给出该曲线的口径说明与买卖点标注。
|
||||
* - **数据只来自归档**:不重新跑回测、不从内存里取,避免「放大页与归档不一致」。
|
||||
* - 归档不存在 → 404;归档类型没有该曲线 → 如实说明并给回归档详情页的入口。
|
||||
*/
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
import { LwChart } from "@/components/charts/LwChart";
|
||||
import { CHART, fmtNum } from "@/components/charts/theme";
|
||||
import { Card, Pill, Banner } from "@/components/ui";
|
||||
import type { LwFormatKey } from "@/components/charts/LwChart";
|
||||
import {
|
||||
drawdownSeries,
|
||||
equitySeries,
|
||||
factorCurveNote,
|
||||
factorFormatKey,
|
||||
factorSeries,
|
||||
monthlySeries,
|
||||
portfolioMarkers,
|
||||
symbolMarkers,
|
||||
symbolSeries,
|
||||
} from "@/lib/chartSeries";
|
||||
import { experimentKindLabel } from "@/lib/labels";
|
||||
import type { BacktestResult, ExperimentDetail } from "@/lib/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const BACKEND = (process.env.BACKEND_API_URL ?? "http://127.0.0.1:8000").replace(/\/$/, "");
|
||||
/** 放大页的主要目的就是「看大图」,给足高度(窄屏靠 CSS 缩到视口内) */
|
||||
const CHART_HEIGHT = 640;
|
||||
|
||||
async function serverGet<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
const r = await fetch(`${BACKEND}/api${path}`, { cache: "no-store" });
|
||||
if (!r.ok) return null;
|
||||
return (await r.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface Option {
|
||||
key: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
/** 该归档里所有可放大的曲线(顺序即推荐阅读顺序) */
|
||||
function optionsFor(result: BacktestResult): Option[] {
|
||||
const out: Option[] = [
|
||||
{ key: "equity", label: "组合净值", hint: "含买卖点(成交日)" },
|
||||
{ key: "drawdown", label: "回撤", hint: "距历史最高的回撤(%)" },
|
||||
];
|
||||
for (const f of result.factor_curves ?? []) {
|
||||
out.push({ key: `factor:${f.name}`, label: `因子 · ${f.label}`, hint: "持仓加权平均原始值" });
|
||||
}
|
||||
for (const c of result.symbol_curves ?? []) {
|
||||
out.push({ key: `sym:${c.symbol}`, label: `个股 · ${c.symbol}`, hint: "持仓期累计收益(%)" });
|
||||
}
|
||||
if ((result.monthly_returns ?? []).length) {
|
||||
out.push({ key: "monthly", label: "月度收益", hint: "每月收益(%)" });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default async function ChartPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ s?: string }>;
|
||||
}) {
|
||||
const { id: rawId } = await params;
|
||||
const id = decodeURIComponent(rawId ?? "");
|
||||
const sp = await searchParams;
|
||||
const detail = await serverGet<ExperimentDetail>(`/experiments/${encodeURIComponent(id)}`);
|
||||
if (!detail) notFound();
|
||||
|
||||
const result = detail.result as BacktestResult | null;
|
||||
const isBacktest = Boolean(result && Array.isArray(result.equity_curve));
|
||||
if (!isBacktest) {
|
||||
return (
|
||||
<Card icon="chartLine" title="该归档没有可放大的回测曲线">
|
||||
<Banner tone="info">
|
||||
归档 {id} 的类型是「{experimentKindLabel(detail.kind)}」,它的结果结构里没有净值 /
|
||||
因子曲线(这些曲线只在回测归档里)。这不是错误,只是曲线放大页不适用于该类型。
|
||||
</Banner>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Link className="btn btn--sm" href={`/experiments/${encodeURIComponent(id)}`}>
|
||||
<span>打开归档详情</span>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const res = result as BacktestResult;
|
||||
const options = optionsFor(res);
|
||||
const want = sp?.s ?? options[0]?.key ?? "equity";
|
||||
const current = options.find((o) => o.key === want) ?? options[0];
|
||||
const factorKey = current?.key.startsWith("factor:") ? current.key.slice("factor:".length) : null;
|
||||
const symKey = current?.key.startsWith("sym:") ? current.key.slice("sym:".length) : null;
|
||||
|
||||
let series = equitySeries(res);
|
||||
let markers = portfolioMarkers(
|
||||
res.fills,
|
||||
new Set(res.equity_curve.map((p) => p.date))
|
||||
);
|
||||
let formatKey: LwFormatKey = "num";
|
||||
let note = "组合净值(元):每日收盘后按持仓市值结算;▲ 绿 = 当日有买入成交,▼ 红 = 当日有卖出成交。";
|
||||
let zeroLine = false;
|
||||
|
||||
if (factorKey) {
|
||||
const curve = (res.factor_curves ?? []).find((f) => f.name === factorKey);
|
||||
if (curve) {
|
||||
const idx = (res.factor_curves ?? []).findIndex((f) => f.name === factorKey);
|
||||
series = [factorSeries(curve, idx)];
|
||||
formatKey = factorFormatKey(curve);
|
||||
note = factorCurveNote(curve);
|
||||
// 因子曲线上的买卖点:把组合的成交日标在因子曲线上,直接看「买卖发生在什么水平」
|
||||
markers = portfolioMarkers(res.fills, new Set(curve.points.map((p) => p.date)));
|
||||
}
|
||||
} else if (symKey) {
|
||||
const curve = (res.symbol_curves ?? []).find((c) => c.symbol === symKey);
|
||||
if (curve) {
|
||||
series = symbolSeries(curve);
|
||||
markers = symbolMarkers(curve);
|
||||
formatKey = "pct2";
|
||||
note =
|
||||
`${curve.symbol} 持仓期间的累计收益率(%,以建仓日收盘为 0% 基准,按日复利),` +
|
||||
"只在该股持仓的交易日落点;买卖点为实际成交。";
|
||||
zeroLine = true;
|
||||
}
|
||||
} else if (current?.key === "drawdown") {
|
||||
series = drawdownSeries(res);
|
||||
markers = [];
|
||||
formatKey = "pct2";
|
||||
note = "回撤(%):净值相对历史最高点的跌幅,越负越深。";
|
||||
zeroLine = true;
|
||||
} else if (current?.key === "monthly") {
|
||||
series = monthlySeries(res);
|
||||
markers = [];
|
||||
formatKey = "pct2";
|
||||
note = "月度收益(%):每个月末相对上月末的净值变化。";
|
||||
zeroLine = true;
|
||||
}
|
||||
|
||||
const seriesLabel = series[0]?.label ?? current?.label ?? "曲线";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="between" style={{ margin: "4px 0 12px", gap: 12, flexWrap: "wrap" }}>
|
||||
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
|
||||
<b style={{ fontSize: 16 }}>曲线放大</b>
|
||||
<Pill tone="accent">{experimentKindLabel(detail.kind)}</Pill>
|
||||
<span className="mono hint">{id}</span>
|
||||
{detail.created_at ? (
|
||||
<span className="hint">归档于 {String(detail.created_at).slice(0, 19).replace("T", " ")}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<Link className="btn btn--sm" href={`/experiments/${encodeURIComponent(id)}`}>
|
||||
<span>打开归档详情</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ gap: 6, flexWrap: "wrap", marginBottom: 10 }}>
|
||||
{options.map((o) => (
|
||||
<Link
|
||||
key={o.key}
|
||||
className={o.key === current?.key ? "btn btn--sm btn--primary" : "btn btn--sm"}
|
||||
href={`/charts/${encodeURIComponent(id)}?s=${encodeURIComponent(o.key)}`}
|
||||
title={o.hint}
|
||||
>
|
||||
<span>{o.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card
|
||||
icon="chartLine"
|
||||
title={seriesLabel}
|
||||
tools={
|
||||
<Pill tone={series[0]?.type === "bar" ? "violet" : "pos"}>
|
||||
{series[0]?.data.length ?? 0} 个点
|
||||
{markers.length ? ` · ${markers.length} 个买卖标注` : ""}
|
||||
</Pill>
|
||||
}
|
||||
>
|
||||
<LwChart
|
||||
series={series}
|
||||
markers={markers}
|
||||
height={CHART_HEIGHT}
|
||||
formatKey={formatKey}
|
||||
zeroLine={zeroLine}
|
||||
ariaLabel={`${seriesLabel}(放大)`}
|
||||
/>
|
||||
<div className="hint" style={{ marginTop: 8 }}>
|
||||
{note} 拖动 / 滚轮可缩放,双击图例可临时隐藏曲线;地址栏 URL 可直接分享或收藏
|
||||
(换设备打开还原同一张图,因为数据来自归档快照)。
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{res.summary ? (
|
||||
<Card icon="gauge" title="这次回测的关键指标(与曲线同一份快照)">
|
||||
<div className="row" style={{ gap: 16, flexWrap: "wrap" }}>
|
||||
<span>
|
||||
区间 <span className="mono">{res.summary.start}</span> ~{" "}
|
||||
<span className="mono">{res.summary.end}</span>
|
||||
</span>
|
||||
<span>
|
||||
总收益{" "}
|
||||
<b className={res.summary.total_return_pct >= 0 ? "tone-pos" : "tone-neg"}>
|
||||
{res.summary.total_return_pct.toFixed(2)}%
|
||||
</b>
|
||||
</span>
|
||||
<span>年化 {res.summary.annual_return_pct.toFixed(2)}%</span>
|
||||
<span>Sharpe {res.summary.sharpe.toFixed(3)}</span>
|
||||
<span>最大回撤 {res.summary.max_drawdown_pct.toFixed(2)}%</span>
|
||||
<span>期末权益 {fmtNum(res.summary.final_equity)}</span>
|
||||
<span className="hint" style={{ color: CHART.faint }}>
|
||||
共 {res.summary.total_trades} 笔成交
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -15,12 +15,8 @@ import {
|
||||
Banner,
|
||||
Progress,
|
||||
Signed,
|
||||
BacktestMetrics,
|
||||
MonthlyReturnsTable,
|
||||
UnimplementedNote,
|
||||
} from "@/components/ui";
|
||||
import { LwChart, type LwSeries } from "@/components/charts/LwChart";
|
||||
import { CHART, fmtNum, fmtPct } from "@/components/charts/theme";
|
||||
import { BacktestResultView } from "@/components/BacktestResultView";
|
||||
import { Icon } from "@/components/icons";
|
||||
|
||||
interface Pick {
|
||||
@@ -37,6 +33,8 @@ export default function ComposePage() {
|
||||
const [start, setStart] = useState("");
|
||||
const [end, setEnd] = useState("");
|
||||
const [result, setResult] = useState<BacktestResult | null>(null);
|
||||
/** 本次结果的归档 id:结果区的「新页面放大」从归档读同一份数据 */
|
||||
const [archiveId, setArchiveId] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [jobId, setJobId] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
@@ -88,6 +86,7 @@ export default function ComposePage() {
|
||||
setError("");
|
||||
setJobId("");
|
||||
setResult(null);
|
||||
setArchiveId(null); // 新一次运行:先清掉上一次的归档 id,避免放大到旧结果
|
||||
try {
|
||||
const spec: ResearchSpec = {
|
||||
type: "backtest",
|
||||
@@ -102,6 +101,7 @@ export default function ComposePage() {
|
||||
const out = await waitJob<BacktestResult>(job_id);
|
||||
if (out.status === "success" && out.result) {
|
||||
setResult(out.result);
|
||||
setArchiveId(out.experimentId ?? null);
|
||||
} else {
|
||||
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
|
||||
}
|
||||
@@ -289,7 +289,13 @@ export default function ComposePage() {
|
||||
{error ? <div style={{ marginTop: 12 }}><Banner tone="error">{error}</Banner></div> : null}
|
||||
</Card>
|
||||
|
||||
{result ? <ResultView result={result} params={{ picks, topN, rebalance, excludeSt, start, end }} /> : null}
|
||||
{result ? (
|
||||
<ResultView
|
||||
result={result}
|
||||
params={{ picks, topN, rebalance, excludeSt, start, end }}
|
||||
archiveId={archiveId}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -297,36 +303,17 @@ export default function ComposePage() {
|
||||
function ResultView({
|
||||
result,
|
||||
params,
|
||||
archiveId,
|
||||
}: {
|
||||
result: BacktestResult;
|
||||
params: { picks: Pick[]; topN: number; rebalance: string; excludeSt: boolean; start: string; end: string };
|
||||
archiveId?: string | null;
|
||||
}) {
|
||||
const s = result.summary;
|
||||
|
||||
// 曲线数据点:LwChart 的 time 用 "YYYY-MM-DD" 字符串,向后端 CurvePoint 的 date 直接映射
|
||||
const equitySeries: LwSeries[] = [
|
||||
{
|
||||
key: "equity",
|
||||
label: "净值",
|
||||
type: "area",
|
||||
color: CHART.pos,
|
||||
data: result.equity_curve.map((p) => ({ time: p.date, value: p.value })),
|
||||
},
|
||||
];
|
||||
const drawdownSeries: LwSeries[] = [
|
||||
{
|
||||
key: "drawdown",
|
||||
label: "回撤",
|
||||
type: "line",
|
||||
color: CHART.neg,
|
||||
data: result.drawdown.map((p) => ({ time: p.date, value: p.value })),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="between" style={{ margin: "6px 0 14px" }}>
|
||||
<div className="row">
|
||||
<div className="row" style={{ flexWrap: "wrap", gap: 8 }}>
|
||||
<Pill tone="pos" icon="check">
|
||||
回测完成
|
||||
</Pill>
|
||||
@@ -334,41 +321,20 @@ function ResultView({
|
||||
<Pill>{params.rebalance === "monthly" ? "月度调仓" : "周度调仓"}</Pill>
|
||||
{params.excludeSt ? <Pill>剔除 ST</Pill> : null}
|
||||
<Pill>{params.start} ~ {params.end}</Pill>
|
||||
<Pill>{params.picks.length} 个因子</Pill>
|
||||
</div>
|
||||
<div className="row" style={{ fontSize: 22, fontWeight: 700 }}>
|
||||
区间收益 <Signed value={s.total_return_pct} suffix="%" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BacktestMetrics s={s} />
|
||||
|
||||
<div className="chart-grid">
|
||||
<Card icon="chartLine" title="净值曲线" tools={<Pill tone="pos">期末 {s.final_equity.toLocaleString()}</Pill>}>
|
||||
<LwChart
|
||||
series={equitySeries}
|
||||
height={300}
|
||||
valueFormat={(v) => fmtNum(v, 2)}
|
||||
ariaLabel="净值曲线"
|
||||
emptyHint="该区间没有净值数据"
|
||||
/>
|
||||
</Card>
|
||||
<Card icon="chartLine" title="回撤(%)" tools={<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>}>
|
||||
<LwChart
|
||||
series={drawdownSeries}
|
||||
height={300}
|
||||
zeroLine
|
||||
valueFormat={(v) => fmtPct(v, 2)}
|
||||
ariaLabel="回撤曲线"
|
||||
emptyHint="该区间没有回撤数据"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card icon="calendar" title="月度收益(%)">
|
||||
<MonthlyReturnsTable rows={result.monthly_returns} />
|
||||
</Card>
|
||||
|
||||
<UnimplementedNote items={result.unimplemented} />
|
||||
{/* 与「回测组合」共用同一个结果视图:买卖理由、因子曲线、放大入口都在那里,
|
||||
两处各写一份迟早出现「同一个结果两套图」的口径漂移 */}
|
||||
<BacktestResultView
|
||||
result={result}
|
||||
name="因子组合"
|
||||
archive={archiveId ? { id: archiveId } : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2185,3 +2185,127 @@ button.chip:hover {
|
||||
min-width: 0;
|
||||
flex: 1 1 320px;
|
||||
}
|
||||
|
||||
/* ---------- 作业反馈条(JobProgress):点「运行」后必须立刻看得见 ---------- */
|
||||
.job-progress {
|
||||
margin: 12px 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.job-progress--run {
|
||||
border-left-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.job-progress--ok {
|
||||
border-left-color: var(--pos);
|
||||
background: rgba(61, 220, 151, 0.08);
|
||||
}
|
||||
.job-progress--bad {
|
||||
border-left-color: var(--neg);
|
||||
background: rgba(255, 106, 118, 0.08);
|
||||
}
|
||||
.job-progress__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.job-progress__title {
|
||||
font-weight: 600;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
.job-progress__meta {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.job-progress__actions {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.job-progress__err {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--surface-1);
|
||||
color: var(--neg);
|
||||
font-size: var(--fs-xs);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ---------- 买卖说明:分段筛选 + 理由单元格 ---------- */
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.seg__btn {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-2);
|
||||
font: inherit;
|
||||
font-size: var(--fs-xs);
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.seg__btn + .seg__btn {
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
.seg__btn.is-on {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
.th-sort {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.reason-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 260px;
|
||||
}
|
||||
.reason-cell__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.reason-cell__text {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-1);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.reason-cell__facts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.reason-brief {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-2);
|
||||
max-width: 160px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user