Ignore PDF prefix truncation AI issues
This commit is contained in:
@@ -4,14 +4,12 @@ import json
|
||||
import os
|
||||
import base64
|
||||
import hashlib
|
||||
import tempfile
|
||||
import secrets
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
@@ -23,7 +21,7 @@ from zoneinfo import ZoneInfo
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request, Response
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -965,12 +963,6 @@ def session_from_request(request: Request) -> dict[str, Any] | None:
|
||||
return SESSIONS.get(token)
|
||||
|
||||
|
||||
def require_admin_user(request: Request) -> None:
|
||||
user = getattr(request.state, "user", {}) or {}
|
||||
if user.get("username") != admin_username():
|
||||
raise HTTPException(status_code=403, detail="只有 admin 可以使用导出功能")
|
||||
|
||||
|
||||
def page_permission_for_path(path: str, method: str) -> str | tuple[str, ...] | None:
|
||||
if path in {"/api/status", "/api/schema"}:
|
||||
return None
|
||||
@@ -1960,22 +1952,6 @@ def get_pdf_path(source_file: str) -> Path | None:
|
||||
return path if path.exists() and path.is_file() else None
|
||||
|
||||
|
||||
def safe_download_name(value: Any, fallback: str = "未命名") -> str:
|
||||
text = re.sub(r'[\\/:*?"<>|\r\n]+', "_", str(value or "").strip())
|
||||
text = re.sub(r"\s+", "_", text).strip("._ ")
|
||||
return text[:80] or fallback
|
||||
|
||||
|
||||
def record_pdf_download_name(record: dict[str, Any]) -> str:
|
||||
source_file = Path(str(record.get("source_file") or "")).name
|
||||
stem = Path(source_file).stem or f"record_{record.get('id', '')}"
|
||||
suffix = Path(source_file).suffix or ".pdf"
|
||||
patient = safe_download_name(record.get("patient_name"), "")
|
||||
inpatient_no = safe_download_name(record.get("inpatient_no") or record.get("medical_record_no"), "")
|
||||
parts = [part for part in [inpatient_no, patient, stem] if part]
|
||||
return "_".join(parts) + suffix
|
||||
|
||||
|
||||
def parse_json_content(content: str) -> Any:
|
||||
text = content.strip()
|
||||
if text.startswith("```"):
|
||||
@@ -2369,8 +2345,9 @@ def build_ai_prompt(record: dict[str, Any], pdf_context: dict[str, Any], privacy
|
||||
6. 如果手术表格中能看到“手术及操作编码”列但对应单元格为空,写“编码栏可见但为空白”,不要写“编码区域未在首页显示”;单元格本来为空且无需补录时不要因此判为 problem。
|
||||
7. 手术操作名称可能因为换行被结构化解析截断;如果 PDF定位文本或局部截图中显示完整多行名称,请把完整名称放入 suggested_updates。
|
||||
8. 门急诊诊断编码只能用于 outpatient_diagnosis_code;主要诊断请修正 discharge_diagnoses 中“诊断类别=主要诊断”的行,不要把门急诊诊断编码复制到 discharge_diagnoses[].疾病编码,除非出院诊断表格对应“疾病编码”单元格本身清楚显示该编码。
|
||||
9. remaining_issues 只写当前文档复核人应该特别注意的内容;不要写如何修改,不要重复 suggested_updates,不要写“无需补录/无需处理/首页原貌”这类已判定无问题的说明。
|
||||
10. 不要编造 PDF 中看不见的内容,不要输出置信度。
|
||||
9. PDF局部截图可能因遮挡、截屏边界或缩放只显示编码/文字前半段;如果 PDF 显示值是结构化字段值的前缀,且没有相反证据,应判为 match/ok,不要写“需确认完整编码”,也不要建议把结构化字段截短。
|
||||
10. remaining_issues 只写当前文档复核人应该特别注意的内容;不要写如何修改,不要重复 suggested_updates,不要写“无需补录/无需处理/首页原貌”这类已判定无问题的说明。
|
||||
11. 不要编造 PDF 中看不见的内容,不要输出置信度。
|
||||
|
||||
必须返回这个 JSON 结构:
|
||||
{{
|
||||
@@ -2538,12 +2515,90 @@ def ai_needs_review_text(value: Any) -> bool:
|
||||
return bool(text) and not any(marker in text for marker in AI_NO_REVIEW_MARKERS)
|
||||
|
||||
|
||||
def ai_compact_compare_value(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
text = text.replace(".", ".").replace("X", "X").replace("x", "x").replace("×", "x")
|
||||
return re.sub(r"\s+", "", text).upper()
|
||||
|
||||
|
||||
def ai_pdf_value_is_structured_prefix(pdf_value: Any, structured_value: Any) -> bool:
|
||||
pdf_text = ai_compact_compare_value(pdf_value)
|
||||
structured_text = ai_compact_compare_value(structured_value)
|
||||
if len(pdf_text) < 3 or len(structured_text) <= len(pdf_text):
|
||||
return False
|
||||
return structured_text.startswith(pdf_text)
|
||||
|
||||
|
||||
def ai_text_pdf_prefix_issue(text: Any) -> bool:
|
||||
compact = re.sub(r"\s+", "", str(text or ""))
|
||||
if not compact:
|
||||
return False
|
||||
patterns = [
|
||||
r"PDF(?:显示|中显示|可见|值)?(?:为|是|[::])?([A-Za-z0-9][A-Za-z0-9.+\-*/xX]*)[,,;;。、]?(?:结构化字段|结构化值|字段|系统字段)(?:为|是|[::])?([A-Za-z0-9][A-Za-z0-9.+\-*/xX]*)",
|
||||
r"PDF[^,,;;。]*?([A-Za-z0-9][A-Za-z0-9.+\-*/xX]{2,})[^,,;;。]*?(?:结构化字段|结构化值|字段|系统字段)[^,,;;。]*?([A-Za-z0-9][A-Za-z0-9.+\-*/xX]{2,})",
|
||||
]
|
||||
return any(
|
||||
ai_pdf_value_is_structured_prefix(match.group(1), match.group(2))
|
||||
for pattern in patterns
|
||||
for match in re.finditer(pattern, compact, flags=re.IGNORECASE)
|
||||
)
|
||||
|
||||
|
||||
def ai_pdf_prefix_truncation_issue(value: Any) -> bool:
|
||||
if isinstance(value, dict):
|
||||
pdf_value = value.get("pdf_value") or value.get("pdf") or value.get("PDF值") or value.get("图片值")
|
||||
structured_value = (
|
||||
value.get("structured_value")
|
||||
or value.get("structured")
|
||||
or value.get("field_value")
|
||||
or value.get("结构化值")
|
||||
or value.get("结构化字段")
|
||||
)
|
||||
if ai_pdf_value_is_structured_prefix(pdf_value, structured_value):
|
||||
return True
|
||||
return ai_text_pdf_prefix_issue(value)
|
||||
return ai_text_pdf_prefix_issue(value)
|
||||
|
||||
|
||||
def ai_has_nonblank_suggested_updates(parsed: dict[str, Any]) -> bool:
|
||||
suggested_updates = parsed.get("suggested_updates")
|
||||
if not isinstance(suggested_updates, list):
|
||||
return False
|
||||
return any(isinstance(item, dict) and not blank_ai_value(suggested_update_value(item)) for item in suggested_updates)
|
||||
|
||||
|
||||
def ai_problem_evidence(parsed: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
evidence = parsed.get("evidence")
|
||||
if not isinstance(evidence, list):
|
||||
return []
|
||||
problem_results = {"mismatch", "uncertain", "missing", "problem", "not_match", "not ok", "not_ok"}
|
||||
return [
|
||||
item
|
||||
for item in evidence
|
||||
if isinstance(item, dict) and str(item.get("result") or "").strip().lower() in problem_results
|
||||
]
|
||||
|
||||
|
||||
def ai_only_pdf_prefix_truncation(parsed: dict[str, Any]) -> bool:
|
||||
if ai_has_nonblank_suggested_updates(parsed):
|
||||
return False
|
||||
issues = ai_remaining_issues(parsed)
|
||||
if issues:
|
||||
return all(ai_pdf_prefix_truncation_issue(issue) for issue in issues)
|
||||
evidence = ai_problem_evidence(parsed)
|
||||
if evidence:
|
||||
return all(ai_pdf_prefix_truncation_issue(item) for item in evidence)
|
||||
return ai_pdf_prefix_truncation_issue(parsed.get("summary")) or ai_pdf_prefix_truncation_issue(parsed.get("decision"))
|
||||
|
||||
|
||||
def normalize_ai_parsed(parsed: dict[str, Any]) -> dict[str, Any]:
|
||||
normalized = dict(parsed)
|
||||
remaining = normalized.get("remaining_issues")
|
||||
if isinstance(remaining, list):
|
||||
normalized["remaining_issues"] = [item for item in remaining if ai_needs_review_text(item)]
|
||||
elif remaining and ai_needs_review_text(remaining):
|
||||
normalized["remaining_issues"] = [
|
||||
item for item in remaining if ai_needs_review_text(item) and not ai_pdf_prefix_truncation_issue(item)
|
||||
]
|
||||
elif remaining and ai_needs_review_text(remaining) and not ai_pdf_prefix_truncation_issue(remaining):
|
||||
normalized["remaining_issues"] = [remaining]
|
||||
else:
|
||||
normalized["remaining_issues"] = []
|
||||
@@ -2567,7 +2622,7 @@ def ai_has_confirmed_problem(parsed: dict[str, Any]) -> bool:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
result = str(item.get("result") or "").strip().lower()
|
||||
if result in {"mismatch", "uncertain", "missing", "problem"}:
|
||||
if result in {"mismatch", "uncertain", "missing", "problem"} and not ai_pdf_prefix_truncation_issue(item):
|
||||
return True
|
||||
|
||||
if resolution in {"false_positive", "ok", "no_issue", "误报", "无问题"}:
|
||||
@@ -2596,6 +2651,8 @@ def ai_remaining_issues(parsed: dict[str, Any]) -> list[Any]:
|
||||
|
||||
|
||||
def ai_has_unresolved_problem(parsed: dict[str, Any]) -> bool:
|
||||
if ai_only_pdf_prefix_truncation(parsed):
|
||||
return False
|
||||
if ai_remaining_issues(parsed):
|
||||
return True
|
||||
|
||||
@@ -2630,22 +2687,20 @@ def ai_classification(parsed: dict[str, Any]) -> str:
|
||||
resolution = str(parsed.get("issue_resolution") or "").strip().lower()
|
||||
ok_values = {"ok", "pass", "passed", "no_issue", "no issue", "无问题", "通过", "已通过"}
|
||||
problem_values = {"problem", "not_ok", "not ok", "confirm", "不ok", "不通过", "需确认", "待确认", "需复核"}
|
||||
if ai_only_pdf_prefix_truncation(parsed):
|
||||
return AI_OK_STATUS
|
||||
if classification in ok_values or decision in ok_values or resolution in {"false_positive", "ok", "no_issue", "误报", "无问题", "通过"}:
|
||||
return AI_OK_STATUS
|
||||
if classification in problem_values or decision in problem_values or resolution in {"confirmed_problem", "uncertain", "problem", "待确认", "已证实"}:
|
||||
if not ai_remaining_issues(parsed) and ai_has_nonblank_suggested_updates(parsed):
|
||||
return AI_OK_STATUS
|
||||
return AI_PROBLEM_STATUS
|
||||
if ai_remaining_issues(parsed) or ai_has_unresolved_problem(parsed):
|
||||
return AI_PROBLEM_STATUS
|
||||
suggested_updates = parsed.get("suggested_updates")
|
||||
if isinstance(suggested_updates, list) and suggested_updates:
|
||||
return AI_OK_STATUS
|
||||
evidence = parsed.get("evidence")
|
||||
if isinstance(evidence, list):
|
||||
for item in evidence:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
result = str(item.get("result") or "").strip().lower()
|
||||
if result in {"mismatch", "uncertain", "missing", "problem"}:
|
||||
if any(not ai_pdf_prefix_truncation_issue(item) for item in ai_problem_evidence(parsed)):
|
||||
return AI_PROBLEM_STATUS
|
||||
return AI_OK_STATUS
|
||||
|
||||
@@ -2774,12 +2829,16 @@ def ai_suggested_updates(result: dict[str, Any], before: dict[str, Any]) -> tupl
|
||||
compare_old = rows[index].get(key)
|
||||
if ai_outpatient_code_leak(path, value, before, compare_old):
|
||||
continue
|
||||
if ai_pdf_value_is_structured_prefix(value, compare_old):
|
||||
continue
|
||||
if comparable(compare_old) == comparable(value):
|
||||
continue
|
||||
rows[index][key] = value
|
||||
new_value = rows
|
||||
label = f"{FIELD_META[field]['label']}[{index + 1}].{key}"
|
||||
|
||||
if index is None and ai_pdf_value_is_structured_prefix(value, compare_old):
|
||||
continue
|
||||
if comparable(compare_old) == comparable(value):
|
||||
continue
|
||||
working[field] = new_value
|
||||
@@ -3397,77 +3456,11 @@ def list_records(q: str = "", status_filter: str = "review_all", limit: int = 30
|
||||
return {"records": rows, "limit": limit, "offset": offset, "has_more": has_more}
|
||||
|
||||
|
||||
@app.get("/api/records/export")
|
||||
def export_records(request: Request, background_tasks: BackgroundTasks, q: str = "", status_filter: str = "review_all"):
|
||||
require_admin_user(request)
|
||||
where_sql, params = record_filter_sql(q, status_filter)
|
||||
max_export = 5000
|
||||
query = sql.SQL(
|
||||
"""
|
||||
SELECT
|
||||
id, source_file, inpatient_no, medical_record_no, patient_name, review_status,
|
||||
NULLIF(review_logs->-1->>'changed_at', '')::timestamp AS last_activity_at
|
||||
FROM {table}
|
||||
{where}
|
||||
{order_by}
|
||||
LIMIT %s
|
||||
"""
|
||||
).format(table=table_identifier(), where=where_sql, order_by=RECORD_LIST_ORDER_SQL)
|
||||
with connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(query, [*params, max_export])
|
||||
rows = [row_to_json(dict(row)) for row in cur.fetchall()]
|
||||
|
||||
export_items: list[tuple[dict[str, Any], Path]] = []
|
||||
for row in rows:
|
||||
pdf_path = get_pdf_path(row.get("source_file") or "")
|
||||
if pdf_path:
|
||||
export_items.append((row, pdf_path))
|
||||
if not export_items:
|
||||
raise HTTPException(status_code=404, detail="当前筛选下没有可导出的 PDF")
|
||||
|
||||
handle = tempfile.NamedTemporaryFile(prefix="frontpage_export_", suffix=".zip", delete=False)
|
||||
zip_path = Path(handle.name)
|
||||
handle.close()
|
||||
used_names: set[str] = set()
|
||||
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
|
||||
for index, (record, pdf_path) in enumerate(export_items, start=1):
|
||||
name = record_pdf_download_name(record)
|
||||
arcname = f"{index:04d}_{name}"
|
||||
while arcname in used_names:
|
||||
arcname = f"{index:04d}_{record.get('id')}_{name}"
|
||||
used_names.add(arcname)
|
||||
archive.write(pdf_path, arcname)
|
||||
background_tasks.add_task(zip_path.unlink, missing_ok=True)
|
||||
stamp = datetime.now(APP_TIMEZONE).strftime("%Y%m%d_%H%M%S")
|
||||
return FileResponse(
|
||||
zip_path,
|
||||
media_type="application/zip",
|
||||
filename=f"患者首页影像导出_{status_filter}_{stamp}.zip",
|
||||
background=background_tasks,
|
||||
headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/records/{record_id}")
|
||||
def get_record(record_id: int):
|
||||
return {"record": fetch_record(record_id)}
|
||||
|
||||
|
||||
@app.get("/api/records/{record_id}/export")
|
||||
def export_record(record_id: int, request: Request):
|
||||
require_admin_user(request)
|
||||
record = fetch_record(record_id)
|
||||
pdf_path = get_pdf_path(record.get("source_file") or "")
|
||||
if not pdf_path:
|
||||
raise HTTPException(status_code=404, detail="PDF 文件不存在")
|
||||
return FileResponse(
|
||||
pdf_path,
|
||||
media_type="application/pdf",
|
||||
filename=record_pdf_download_name(record),
|
||||
headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/records/{record_id}/ai-question-image/{log_index}/{module_index}")
|
||||
def ai_question_image(record_id: int, log_index: int, module_index: int):
|
||||
if log_index < 0 or module_index < 0:
|
||||
|
||||
@@ -190,10 +190,6 @@ function userPermissions() {
|
||||
return state.currentUser?.permissions || {};
|
||||
}
|
||||
|
||||
function isAdminUser() {
|
||||
return state.currentUser?.source === "env" || state.currentUser?.username === "admin";
|
||||
}
|
||||
|
||||
function canOpenPage(page) {
|
||||
const key = page === "auditHistory" ? "audit_history" : page;
|
||||
return userPermissions()[key] !== false;
|
||||
@@ -402,9 +398,6 @@ function renderRecords() {
|
||||
const active = record.id === state.selectedId ? " is-active" : "";
|
||||
const manual = record.manual_corrected ? " · 人工已改" : "";
|
||||
const activity = record.last_activity_at ? `<span class="record-time">最近 ${escapeHtml(formatTime(record.last_activity_at))}</span>` : "";
|
||||
const exportButton = isAdminUser() && record.has_pdf
|
||||
? `<button class="record-download" type="button" data-export-record="${record.id}" title="下载影像/PDF">下载</button>`
|
||||
: "";
|
||||
return `
|
||||
<div class="record-item${active}" role="button" tabindex="0" data-id="${record.id}">
|
||||
<div class="record-main">
|
||||
@@ -414,17 +407,13 @@ function renderRecords() {
|
||||
<div class="record-sub">
|
||||
${escapeHtml(record.inpatient_no || record.medical_record_no || "")} · ${escapeHtml(record.major_department || record.discharge_dept || "")}${manual}<br>
|
||||
${escapeHtml(record.primary_diagnosis || "")}
|
||||
<div class="record-footer">
|
||||
<span>${activity}</span>
|
||||
${exportButton}
|
||||
</div>
|
||||
${activity}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("") + loadingNotice + endNotice;
|
||||
list.querySelectorAll(".record-item").forEach((item) => {
|
||||
item.addEventListener("click", (event) => {
|
||||
if (event.target.closest("[data-export-record]")) return;
|
||||
item.addEventListener("click", () => {
|
||||
selectRecord(Number(item.dataset.id));
|
||||
});
|
||||
item.addEventListener("keydown", (event) => {
|
||||
@@ -433,18 +422,11 @@ function renderRecords() {
|
||||
selectRecord(Number(item.dataset.id));
|
||||
});
|
||||
});
|
||||
list.querySelectorAll("[data-export-record]").forEach((button) => {
|
||||
button.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
exportRecordPdf(Number(button.dataset.exportRecord));
|
||||
});
|
||||
});
|
||||
list.scrollTop = previousScrollTop;
|
||||
}
|
||||
|
||||
function renderFilterActions() {
|
||||
const button = $("approveAiNoIssueBtn");
|
||||
const exportButton = $("batchExportBtn");
|
||||
if (!button) return;
|
||||
const filter = $("statusFilter")?.value || "review_all";
|
||||
const aiPassedCount = Number(state.status?.ai_passed || 0);
|
||||
@@ -452,33 +434,6 @@ function renderFilterActions() {
|
||||
button.classList.toggle("is-hidden", !shouldShow);
|
||||
button.disabled = !shouldShow || aiPassedCount <= 0 || Boolean(state.bulkJob?.running);
|
||||
button.textContent = aiPassedCount > 0 ? `通过所有AI已通过(${aiPassedCount})` : "通过所有AI已通过";
|
||||
if (exportButton) {
|
||||
exportButton.classList.toggle("is-hidden", !isAdminUser());
|
||||
exportButton.disabled = !isAdminUser() || state.recordsLoading;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadUrl(url) {
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.rel = "noopener";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
function exportRecordPdf(recordId) {
|
||||
if (!isAdminUser() || !recordId) return;
|
||||
downloadUrl(`/api/records/${recordId}/export`);
|
||||
}
|
||||
|
||||
function exportCurrentFilter() {
|
||||
if (!isAdminUser()) return;
|
||||
const params = new URLSearchParams({
|
||||
q: $("searchInput").value.trim(),
|
||||
status_filter: $("statusFilter").value || "review_all",
|
||||
});
|
||||
downloadUrl(`/api/records/export?${params.toString()}`);
|
||||
}
|
||||
|
||||
function scrollSelectedRecordIntoView() {
|
||||
@@ -711,7 +666,7 @@ function renderTargetSummary(targets, targetId = "targetSummary", formId = "deta
|
||||
<ul>${baseNotesHtml}</ul>
|
||||
</details>
|
||||
${targets.hasAiNotes ? `
|
||||
<details class="collapsible-panel target-panel is-ai-target">
|
||||
<details class="collapsible-panel target-panel is-ai-target" open>
|
||||
<summary>
|
||||
<strong>AI建议复核点</strong>
|
||||
<span>${aiGroups.length ? `${aiGroups.length} 个模块` : "无待看点"}</span>
|
||||
@@ -1894,7 +1849,6 @@ async function boot() {
|
||||
$("aiAllBtn").addEventListener("click", () => runAiReview("all"));
|
||||
$("aiCancelBtn").addEventListener("click", () => handleAiProgressAction().catch((error) => showMessage(error.message, "error")));
|
||||
$("approveAiNoIssueBtn").addEventListener("click", approveAiNoIssueRecords);
|
||||
$("batchExportBtn").addEventListener("click", exportCurrentFilter);
|
||||
$("pdfZoomOutBtn").addEventListener("click", () => changePdfZoom(-PDF_ZOOM_STEP));
|
||||
$("pdfZoomInBtn").addEventListener("click", () => changePdfZoom(PDF_ZOOM_STEP));
|
||||
$("pdfZoomResetBtn").addEventListener("click", () => setPdfZoom(100));
|
||||
|
||||
@@ -98,7 +98,6 @@
|
||||
<button id="aiCancelBtn" type="button">中止AI</button>
|
||||
</div>
|
||||
<button id="approveAiNoIssueBtn" class="bulk-action is-hidden" type="button">通过所有AI已通过</button>
|
||||
<button id="batchExportBtn" class="bulk-action export-action is-hidden" type="button">批量导出当前筛选</button>
|
||||
</div>
|
||||
<div class="record-list" id="recordList" tabindex="0"></div>
|
||||
</aside>
|
||||
|
||||
@@ -362,12 +362,6 @@ h2 {
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.export-action {
|
||||
border-color: #b6c3d0;
|
||||
background: #f7fafc;
|
||||
color: var(--accent-dark);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
@@ -478,32 +472,8 @@ textarea {
|
||||
|
||||
.record-time {
|
||||
display: block;
|
||||
color: #7b8794;
|
||||
}
|
||||
|
||||
.record-footer {
|
||||
min-height: 26px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.record-download {
|
||||
flex: 0 0 auto;
|
||||
min-height: 24px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #b6c3d0;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.record-download:hover {
|
||||
border-color: var(--accent);
|
||||
background: #eef6fb;
|
||||
color: #7b8794;
|
||||
}
|
||||
|
||||
.pdf-panel {
|
||||
|
||||
Reference in New Issue
Block a user