feat: 查标第 1 步——上传投标分册 → 检查 → 逐条处理 → 复查沿用
后端 - check 模块:检查预览、发起、进度、取消、记录;六个阶段(准备投标文件、响应匹配、规则、证据校验、 状态沿用、汇总);问题列表、处理、撤销、批量处理(只限提示级)。 - 每条清单项的检查结论 check_item_result(C9,迁移 0005):「已响应」也带投标原文出处; 没有校验通过的证据不能判已响应(C3)。级别从清单项算,不让模型定;说明不写整改建议。 - 响应匹配先只有第三层:内存 BM25 检索候选句 + check.match v1 提示词,检索不到不调模型, 漏答记 missing_answer,模型临时错误只影响那一批。 - 计费:免费版不记流水;专业版 24 小时内复查免扣(记一行 0);expected_charge 不一致时拒绝不扣点; 失败、取消退回;投标文件读不出文字按输入问题失败、不扣点。 - 投标分册:类型(volume_kind)和版本;电子投标平台加密格式在上传时拦下。 - 解析流水线前几个阶段拆到 document/pipeline/normalize.py,解析和检查共用。 - ops 模块:反馈接口;致命问题确认无误的说明自动进误报反馈(U4)。 - SK1:Valkey 客户端短超时、SSE 连接池上限。SK2:CI 加 Gotenberg、直接跑 init-roles.sh。 前端 - 上传投标文件并检查页、查标页(进度、覆盖范围、按级别筛选、逐条处理和行内说明、撤销、原文抽屉、 已处理、本次已解决);项目头抽成公共组件,加「查标」标签页;工作台「下一步」覆盖查标阶段。 契约、文档:openapi.yaml 加 item-results、volume_kind、coverage 字段;data-model、api-design §10、 架构 §8 / §13(SK14)、AGENTS.md 同步。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
448
apps/web/src/pages/ResultsPage.vue
Normal file
448
apps/web/src/pages/ResultsPage.vue
Normal file
@@ -0,0 +1,448 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 查标(原型 results.html,FR-M3-03、07、08)。第 1 步的最小版本:
|
||||
* - 检查进行中显示进度和取消;失败时写明原因和是否退回额度;
|
||||
* - 顶部是检查覆盖范围:查了什么、没查什么(m3-plan §2.5,红线 3);
|
||||
* - 问题按级别筛选,逐条处理:确认无误 / 已修改 / 忽略并注明原因;致命问题确认无误也要写说明(U4),
|
||||
* 说明框在行内展开;处理后显示署名,可以撤销(U 键在第 6 步);
|
||||
* - 每个问题带招标原文和投标原文两侧出处,「未找到」写明找过哪些分册;点出处在右侧抽屉里看原文;
|
||||
* - 只有最近一次检查的问题可以处理,历史批次只读。
|
||||
* TODO(第 6 步):键盘操作、批量处理「提示」、判断有误反馈、需人工核对、导出报告。
|
||||
*/
|
||||
import { unwrap } from "@bid-doctor/api-client";
|
||||
import { ChevronDown, ChevronRight, X } from "@lucide/vue";
|
||||
import { useQueryClient } from "@tanstack/vue-query";
|
||||
import { computed, ref } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
import CitationBlock from "@/components/CitationBlock.vue";
|
||||
import PdfViewer, { type ViewerTarget } from "@/components/PdfViewer.vue";
|
||||
import ProjectHead from "@/components/ProjectHead.vue";
|
||||
import { api, errorTitle, isApiError } from "@/lib/api";
|
||||
import { useCrumbs } from "@/lib/crumbs";
|
||||
import { qk } from "@/lib/keys";
|
||||
import { CHECK_STEPS, checkStepIndex, FINDING_ACTION, fmtDateTime, SEVERITY, SEVERITY_CLASS } from "@/lib/labels";
|
||||
import { TERMINAL_JOB, useCheckRun, useFindings, useProject, type Citation, type Finding } from "@/lib/queries";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
const props = defineProps<{ projectId: string; runId: string }>();
|
||||
const qc = useQueryClient();
|
||||
const project = useProject(() => props.projectId);
|
||||
const runQ = useCheckRun(() => props.runId);
|
||||
const run = computed(() => runQ.data.value);
|
||||
const done = computed(() => run.value?.status === "succeeded");
|
||||
const findingsQ = useFindings(() => props.runId, done);
|
||||
const p = computed(() => project.data.value);
|
||||
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "查标" }]);
|
||||
|
||||
const readOnly = computed(() => !run.value?.is_latest || p.value?.status === "submitted" || p.value?.status === "archived");
|
||||
const step = computed(() => checkStepIndex(run.value?.stage));
|
||||
|
||||
/* ---------- 进行中 ---------- */
|
||||
const cancelling = ref(false);
|
||||
async function cancel() {
|
||||
cancelling.value = true;
|
||||
try {
|
||||
await unwrap(api.POST("/check-runs/{run_id}/cancel", { params: { path: { run_id: props.runId } } }));
|
||||
void qc.invalidateQueries({ queryKey: qk.checkRun(props.runId) });
|
||||
} catch (e) {
|
||||
toast(errorTitle(e), { ok: false });
|
||||
} finally {
|
||||
cancelling.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 问题 ---------- */
|
||||
type Sev = "" | Finding["severity"];
|
||||
const sev = ref<Sev>("");
|
||||
const all = computed(() => findingsQ.data.value?.items ?? []);
|
||||
const live = computed(() => all.value.filter((f) => f.presence !== "resolved"));
|
||||
const todo = computed(() => live.value.filter((f) => f.status === "pending" && (!sev.value || f.severity === sev.value)));
|
||||
const handled = computed(() => live.value.filter((f) => f.status !== "pending" && (!sev.value || f.severity === sev.value)));
|
||||
const resolved = computed(() => all.value.filter((f) => f.presence === "resolved"));
|
||||
const count = (s: Sev) => live.value.filter((f) => f.status === "pending" && (!s || f.severity === s)).length;
|
||||
const showHandled = ref(false);
|
||||
const showResolved = ref(false);
|
||||
|
||||
/** 行内说明框(U4):忽略、致命问题的确认无误必须写说明 */
|
||||
const noteFor = ref<{ id: string; status: "confirmed_ok" | "ignored" } | null>(null);
|
||||
const note = ref("");
|
||||
const noteErr = ref("");
|
||||
const busyId = ref<string | null>(null);
|
||||
const needsNote = (f: Finding, status: string) => status === "ignored" || (status === "confirmed_ok" && f.severity === "fatal");
|
||||
|
||||
function act(f: Finding, status: "confirmed_ok" | "modified" | "ignored") {
|
||||
if (needsNote(f, status)) {
|
||||
noteFor.value = { id: f.id, status: status as "confirmed_ok" | "ignored" };
|
||||
note.value = "";
|
||||
noteErr.value = "";
|
||||
return;
|
||||
}
|
||||
void resolve(f, status);
|
||||
}
|
||||
|
||||
async function resolve(f: Finding, status: "confirmed_ok" | "modified" | "ignored", text?: string) {
|
||||
if (text !== undefined && text.trim().length < 5) {
|
||||
noteErr.value = status === "ignored" ? "至少写 5 个字,说明为什么不处理" : "请写一句说明,至少 5 个字";
|
||||
return;
|
||||
}
|
||||
busyId.value = f.id;
|
||||
try {
|
||||
await unwrap(
|
||||
api.POST("/findings/{finding_id}/resolve", { params: { path: { finding_id: f.id } }, body: { status, ...(text ? { note: text.trim() } : {}) } }),
|
||||
);
|
||||
noteFor.value = null;
|
||||
toast(`已标记为「${FINDING_ACTION[status]}」`, { undo: () => void reopen(f) });
|
||||
refresh();
|
||||
} catch (e) {
|
||||
if (isApiError(e, "note_required")) noteErr.value = "请填写说明";
|
||||
else toast(errorTitle(e), { ok: false });
|
||||
} finally {
|
||||
busyId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function reopen(f: Finding) {
|
||||
try {
|
||||
await unwrap(api.POST("/findings/{finding_id}/reopen", { params: { path: { finding_id: f.id } } }));
|
||||
refresh();
|
||||
} catch (e) {
|
||||
toast(errorTitle(e), { ok: false });
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
void qc.invalidateQueries({ queryKey: qk.findings(props.runId) });
|
||||
void qc.invalidateQueries({ queryKey: qk.project(props.projectId) });
|
||||
void qc.invalidateQueries({ queryKey: qk.projects });
|
||||
}
|
||||
|
||||
/* ---------- 原文抽屉 ---------- */
|
||||
const viewer = ref<{ doc: { id: string; label: string }; target: ViewerTarget & { id: string } } | null>(null);
|
||||
function openCitation(c: Citation) {
|
||||
viewer.value = {
|
||||
doc: { id: c.document_id, label: c.document_label ?? "" },
|
||||
target: { id: c.id, page: c.page_no, quads: c.quads, sha: c.normalized_sha256 },
|
||||
};
|
||||
}
|
||||
|
||||
const metaLine = computed(() => {
|
||||
const r = run.value;
|
||||
if (!r) return "";
|
||||
const files = (r.documents ?? []).map((d) => d.label).join("、");
|
||||
return [`第 ${r.run_no} 次检查`, fmtDateTime(r.created_at), files, `清单 v${r.checklist_version_no}(${r.checklist_confirmed ? "已确认" : "未确认"})`]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
});
|
||||
const errorText = computed(() => {
|
||||
const e = run.value?.error;
|
||||
if (!e) return "";
|
||||
return `${e.message}。${e.refunded ? "已扣的额度已退回。" : "这次检查没有扣点。"}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProjectHead :project="p" tab="check" />
|
||||
<div class="content">
|
||||
<!-- 进行中 -->
|
||||
<section v-if="run && !TERMINAL_JOB.has(run.status)" class="card running" aria-live="polite">
|
||||
<h2>正在检查<span class="fig">{{ run.progress }}</span>%</h2>
|
||||
<div class="bar" role="progressbar" :aria-valuenow="run.progress" aria-valuemin="0" aria-valuemax="100" aria-label="检查进度"><i :style="{ width: `${run.progress}%` }"></i></div>
|
||||
<ol class="steps">
|
||||
<li v-for="(s, i) in CHECK_STEPS" :key="s.label" :class="{ done: i < step, cur: i === step }">{{ s.label }}</li>
|
||||
</ol>
|
||||
<div class="acts">
|
||||
<span class="cap muted">可以离开这个页面,检查完成后会通知你。</span>
|
||||
<button type="button" class="btn sm text" :disabled="cancelling || run.cancel_requested" @click="cancel">{{ run.cancel_requested ? "正在取消…" : "取消检查" }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 失败 / 取消 -->
|
||||
<div v-else-if="run && run.status !== 'succeeded'" class="alert warn" role="alert">
|
||||
<span class="ico w" aria-hidden="true">!</span>
|
||||
<p>{{ run.status === "cancelled" ? "检查已取消,额度已退回。" : `检查没有完成:${errorText}` }}</p>
|
||||
<RouterLink class="btn sm" :to="{ name: 'bid-check', params: { projectId } }">重新检查</RouterLink>
|
||||
</div>
|
||||
|
||||
<template v-else-if="run">
|
||||
<div class="rbar">
|
||||
<div>
|
||||
<h2>检查结果</h2>
|
||||
<p class="meta" :title="metaLine">{{ metaLine }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<RouterLink class="btn" :to="{ name: 'bid-check', params: { projectId } }">重新检查</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!run.is_latest" class="alert warn" role="note"><span class="ico w" aria-hidden="true">!</span><p>这是历史检查,只能查看。</p></div>
|
||||
|
||||
<section v-if="run.coverage" class="card coverage" aria-labelledby="h-cov">
|
||||
<h2 id="h-cov" class="vh">检查覆盖范围</h2>
|
||||
<div>
|
||||
<h3>查了</h3>
|
||||
<ul><li v-for="t in run.coverage.checked ?? []" :key="t">{{ t }}</li></ul>
|
||||
</div>
|
||||
<div class="no">
|
||||
<h3>没查</h3>
|
||||
<ul><li v-for="t in run.coverage.not_checked ?? []" :key="t">{{ t }}</li></ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card flush results" aria-label="检查结果">
|
||||
<div class="card-h">
|
||||
<div class="seg" role="group" aria-label="按级别筛选">
|
||||
<button type="button" :aria-pressed="sev === ''" @click="sev = ''">全部 <span class="fig">{{ count("") }}</span></button>
|
||||
<button v-for="s in (['fatal', 'important', 'hint'] as const)" :key="s" type="button" :aria-pressed="sev === s" @click="sev = s">
|
||||
<span class="sev" :class="SEVERITY_CLASS[s]">{{ SEVERITY[s] }}<span class="fig">{{ count(s) }}</span></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="findingsQ.isPending.value" class="lst-pad"><span class="skel t"></span><span class="skel"></span></div>
|
||||
<template v-else>
|
||||
<article v-for="f in [...todo, ...(showHandled ? handled : [])]" :key="f.id" class="finding">
|
||||
<div>
|
||||
<div class="f-head">
|
||||
<span class="sev" :class="SEVERITY_CLASS[f.severity]">{{ SEVERITY[f.severity] }}</span>
|
||||
<span class="f-title">{{ f.title }}</span>
|
||||
<span v-if="f.presence === 'carried'" class="flag">沿用<template v-if="f.carried_status === 'modified'">:上次已修改,这次仍查出</template></span>
|
||||
</div>
|
||||
<p class="f-text">{{ f.explanation }}</p>
|
||||
|
||||
<div v-if="f.status !== 'pending'" class="sign" :class="{ ign: f.status === 'ignored' }">
|
||||
<span aria-hidden="true">{{ f.status === "ignored" ? "–" : "✓" }}</span><b>{{ f.resolved_by?.name }}</b>
|
||||
<span v-if="f.resolved_at" class="fig">{{ fmtDateTime(f.resolved_at) }}</span><span>{{ FINDING_ACTION[f.status] }}</span>
|
||||
<button v-if="!readOnly" type="button" class="undo" @click="reopen(f)">撤销</button>
|
||||
</div>
|
||||
<p v-if="f.status !== 'pending' && f.resolution_note" class="sign-note">{{ f.status === "ignored" ? "原因" : "说明" }}:{{ f.resolution_note }}</p>
|
||||
|
||||
<div v-else-if="noteFor?.id === f.id" class="u4">
|
||||
<div class="field" :class="{ err: noteErr }">
|
||||
<label :for="`u4-${f.id}`">{{ noteFor.status === "confirmed_ok" ? "确认无误的说明" : "忽略原因" }} <small>(必填,至少 5 个字)</small></label>
|
||||
<textarea :id="`u4-${f.id}`" v-model="note" class="input" maxlength="200" :aria-invalid="!!noteErr" @keydown.ctrl.enter="resolve(f, noteFor.status, note)" @keydown.esc="noteFor = null"></textarea>
|
||||
<span class="msg"><template v-if="noteErr"><span class="ico w" aria-hidden="true">!</span>{{ noteErr }}</template><template v-else>{{ noteFor.status === "confirmed_ok" ? "说明会同时作为误报反馈提交" : "「已忽略」在报告里单独列出" }}</template></span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button type="button" class="btn sm" :disabled="busyId === f.id" @click="resolve(f, noteFor.status, note)">{{ noteFor.status === "confirmed_ok" ? "确认无误" : "忽略这一项" }}</button>
|
||||
<button type="button" class="btn sm text" @click="noteFor = null">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!readOnly" class="f-actions">
|
||||
<button type="button" class="btn sm" :disabled="busyId === f.id" @click="act(f, 'confirmed_ok')">确认无误</button>
|
||||
<button type="button" class="btn sm" :disabled="busyId === f.id" @click="act(f, 'modified')">已修改</button>
|
||||
<button type="button" class="btn sm" :disabled="busyId === f.id" @click="act(f, 'ignored')">忽略并注明原因</button>
|
||||
</div>
|
||||
</div>
|
||||
<aside>
|
||||
<CitationBlock v-for="c in f.tender_citations" :key="c.id" :citation="c" :active="viewer?.target.id === c.id" @open="openCitation(c)" />
|
||||
<CitationBlock v-for="c in f.bid_citations" :key="c.id" :citation="c" :active="viewer?.target.id === c.id" @open="openCitation(c)" />
|
||||
<div v-if="f.type === 'unresponded' && f.search_scope" class="cite missing">
|
||||
<span class="cite-src">投标文件 · 未找到</span>
|
||||
<p>找过:{{ (f.search_scope.documents ?? []).join("、") || "全部分册" }}</p>
|
||||
</div>
|
||||
</aside>
|
||||
</article>
|
||||
|
||||
<div v-if="!todo.length" class="empty sm">
|
||||
<b>{{ live.length ? (sev ? "这个级别的问题都已处理" : "全部问题都已处理") : "没有发现问题" }}</b>
|
||||
<p>{{ live.length ? "已处理的在下方,可以撤销。" : "只表示本次检查的范围内没有发现问题,不代表投标文件没有问题。没查的内容见上方「没查」。" }}</p>
|
||||
</div>
|
||||
|
||||
<button v-if="handled.length" type="button" class="group-h" :aria-expanded="showHandled" @click="showHandled = !showHandled">
|
||||
<component :is="showHandled ? ChevronDown : ChevronRight" class="ic" :stroke-width="1.5" />已处理(<span class="fig">{{ handled.length }}</span>)
|
||||
</button>
|
||||
<button v-if="resolved.length" type="button" class="group-h" :aria-expanded="showResolved" @click="showResolved = !showResolved">
|
||||
<component :is="showResolved ? ChevronDown : ChevronRight" class="ic" :stroke-width="1.5" />本次已解决(<span class="fig">{{ resolved.length }}</span>)<span class="sub">上次有、这次没再发现,已自动关闭</span>
|
||||
</button>
|
||||
<template v-if="showResolved">
|
||||
<div v-for="f in resolved" :key="f.id" class="resolved">
|
||||
<span class="sev" :class="SEVERITY_CLASS[f.severity]">{{ SEVERITY[f.severity] }}</span><s>{{ f.title }}</s>
|
||||
<span v-if="f.carried_status && f.carried_status !== 'pending'" class="cap">上次:{{ FINDING_ACTION[f.carried_status] }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
<div v-else-if="runQ.error.value" class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ errorTitle(runQ.error.value) }}</p></div>
|
||||
</div>
|
||||
|
||||
<aside v-if="viewer" class="drawer" aria-label="原文">
|
||||
<button type="button" class="icon-btn sm close" aria-label="关闭原文" @click="viewer = null"><X class="ic" :stroke-width="1.5" /></button>
|
||||
<PdfViewer :document-id="viewer.doc.id" :document-label="viewer.doc.label" :target="viewer.target" />
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.running h2 {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
align-items: baseline;
|
||||
margin: 0 0 var(--sp-3);
|
||||
font: var(--fw-bold) var(--fs-title) / var(--lh-title) var(--font-sans);
|
||||
}
|
||||
.running .bar {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--c-n100);
|
||||
overflow: hidden;
|
||||
}
|
||||
.running .bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--ai);
|
||||
transition: width var(--dur-base) var(--ease);
|
||||
}
|
||||
.steps {
|
||||
display: flex;
|
||||
gap: var(--sp-6);
|
||||
margin: var(--sp-4) 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.steps .done {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.steps .cur {
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
.running .acts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
.rbar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--sp-4);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
.rbar h2 {
|
||||
margin: 0;
|
||||
font: var(--fw-bold) var(--fs-title) / var(--lh-title) var(--font-sans);
|
||||
}
|
||||
.rbar .meta {
|
||||
margin: 2px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--fs-caption);
|
||||
}
|
||||
.rbar .actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
.coverage {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--sp-6);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
.coverage h3 {
|
||||
margin: 0 0 var(--sp-1);
|
||||
font-size: var(--fs-body);
|
||||
}
|
||||
.coverage ul {
|
||||
margin: 0;
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
.coverage .no {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.results .card-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.seg {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.seg button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
padding: 2px var(--sp-2);
|
||||
border-radius: var(--radius-ctl);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.seg button[aria-pressed="true"] {
|
||||
background: var(--c-n100);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.sign.ign {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.sign .undo {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.sign-note {
|
||||
margin: var(--sp-1) 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--fs-caption);
|
||||
}
|
||||
.u4 {
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
.u4 .field {
|
||||
max-width: none;
|
||||
}
|
||||
.u4 textarea {
|
||||
min-height: 64px;
|
||||
}
|
||||
.u4 .row {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
.cite.missing p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.group-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
width: 100%;
|
||||
padding: var(--sp-3) var(--card-x);
|
||||
border-top: var(--line) solid var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
}
|
||||
.group-h .sub {
|
||||
margin-left: var(--sp-2);
|
||||
font-size: var(--fs-caption);
|
||||
}
|
||||
.resolved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--card-x);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
width: min(640px, 50vw);
|
||||
background: var(--bg-surface);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
}
|
||||
.drawer > :deep(*:last-child) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
/* 关闭按钮挂在抽屉左边缘外侧,不挡查看器自己的翻页按钮 */
|
||||
.drawer .close {
|
||||
position: absolute;
|
||||
top: var(--sp-3);
|
||||
left: calc(-1 * var(--sp-8));
|
||||
background: var(--bg-surface);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user