feat: align surgery API with schemas and extend client tooling
- Refactor app API and schemas; adjust surgery pipeline, repository, and session manager. - Improve consumption TSV logging and consumable vision integration; trim voice resolution. - Add Baidu Face 1:N search script, .env.example entries, and client API integration doc. - Update demo client, staging checklist, surgery interface doc, and related tests; add sample face image. Made-with: Cursor
This commit is contained in:
291
scripts/baidu_face_1n_search.py
Normal file
291
scripts/baidu_face_1n_search.py
Normal file
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env python3
|
||||
"""百度智能云人脸 1:N 搜索(独立脚本,不接入本仓库 FastAPI)。
|
||||
|
||||
对应官方文档:人脸 1:N 搜索 — https://cloud.baidu.com/doc/FACE/s/Gk37c1uzc
|
||||
接口:POST https://aip.baidubce.com/rest/2.0/face/v3/search
|
||||
|
||||
前置条件(本脚本不负责「建库 / 注册人脸」):
|
||||
- 在控制台创建应用并开通「人脸识别」相关接口权限;
|
||||
- 已使用人脸库管理 API 或控制台建立用户组,并向库中注册用户与人脸照片;
|
||||
- 否则搜索会失败(例如未找到匹配用户、人脸库为空等)。人脸库管理说明见产品文档「人脸库管理」章节。
|
||||
|
||||
配置从环境变量读取;启动时会从**仓库根目录**下的 `.env` 与**当前工作目录**下的 `.env` 加载(需已安装 `python-dotenv`,随 pydantic-settings 提供)。
|
||||
|
||||
主要环境变量(详见仓库 `.env.example` 中 Baidu Face 节):
|
||||
BAIDU_FACE_APP_ID、BAIDU_FACE_API_KEY、BAIDU_FACE_SECRET_KEY(必填)
|
||||
BAIDU_FACE_GROUP_ID_LIST(与命令行 --groups 二选一;格式以百度人脸库文档为准,非法值由接口返回错误码)
|
||||
|
||||
用法示例(输入为**文件夹**,遍历其下所有支持的图片并打印识别日志):
|
||||
|
||||
uv run python scripts/baidu_face_1n_search.py /path/to/photos
|
||||
|
||||
支持格式:PNG、JPG、JPEG、BMP(单张 base64 建议 <2M,分辨率 <1920x1080,以官方文档为准)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aip import AipFace
|
||||
from dotenv import load_dotenv
|
||||
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".bmp"}
|
||||
|
||||
|
||||
def _validate_group_id_list(s: str) -> None:
|
||||
"""仅校验列表非空与数量上限;group_id 字符集等由百度接口校验。"""
|
||||
parts = [p.strip() for p in s.split(",") if p.strip()]
|
||||
if not parts:
|
||||
print("错误:group_id_list 解析后为空。", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
if len(parts) > 10:
|
||||
print("错误:group_id 最多 10 个(逗号分隔)。", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def _load_dotenv_files() -> None:
|
||||
load_dotenv(_PROJECT_ROOT / ".env")
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def _env(name: str) -> str:
|
||||
return (os.environ.get(name) or "").strip()
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
v = _env(name)
|
||||
if v.isdigit() or (v.startswith("-") and v[1:].isdigit()):
|
||||
return int(v)
|
||||
return default
|
||||
|
||||
|
||||
def _face_client() -> AipFace:
|
||||
app_id = _env("BAIDU_FACE_APP_ID")
|
||||
api_key = _env("BAIDU_FACE_API_KEY")
|
||||
secret = _env("BAIDU_FACE_SECRET_KEY")
|
||||
if not app_id or not api_key or not secret:
|
||||
print(
|
||||
"错误:未配置百度人脸凭据。\n"
|
||||
"请在 `.env` 或环境中设置:BAIDU_FACE_APP_ID、BAIDU_FACE_API_KEY、"
|
||||
"BAIDU_FACE_SECRET_KEY\n"
|
||||
"(参考仓库 `.env.example` 中 Baidu Face 节;与语音 BAIDU_SPEECH_* 可为不同应用。)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
client = AipFace(app_id, api_key, secret)
|
||||
conn_ms = _env("BAIDU_FACE_CONNECTION_TIMEOUT_MS")
|
||||
sock_ms = _env("BAIDU_FACE_SOCKET_TIMEOUT_MS")
|
||||
if conn_ms.isdigit():
|
||||
client.setConnectionTimeoutInMillis(int(conn_ms))
|
||||
if sock_ms.isdigit():
|
||||
client.setSocketTimeoutInMillis(int(sock_ms))
|
||||
return client
|
||||
|
||||
|
||||
def _read_image_base64(path: Path) -> str:
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(str(path))
|
||||
raw = path.read_bytes()
|
||||
if not raw:
|
||||
raise ValueError("empty file")
|
||||
return base64.b64encode(raw).decode("ascii")
|
||||
|
||||
|
||||
def _ts() -> str:
|
||||
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _list_image_files(folder: Path, *, recursive: bool) -> list[Path]:
|
||||
if not folder.is_dir():
|
||||
print(f"错误:不是有效文件夹:{folder}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
if recursive:
|
||||
out: list[Path] = []
|
||||
for p in folder.rglob("*"):
|
||||
if p.is_file() and p.suffix.lower() in _IMAGE_SUFFIXES:
|
||||
out.append(p)
|
||||
else:
|
||||
out = [
|
||||
p
|
||||
for p in folder.iterdir()
|
||||
if p.is_file() and p.suffix.lower() in _IMAGE_SUFFIXES
|
||||
]
|
||||
return sorted(out, key=lambda p: p.name.lower())
|
||||
|
||||
|
||||
def _search_options_from_env_and_args(args: argparse.Namespace) -> dict[str, Any]:
|
||||
qc = _env("BAIDU_FACE_QUALITY_CONTROL") or "NONE"
|
||||
lc = _env("BAIDU_FACE_LIVENESS_CONTROL") or "NONE"
|
||||
if args.quality_control is not None:
|
||||
qc = args.quality_control
|
||||
if args.liveness_control is not None:
|
||||
lc = args.liveness_control
|
||||
max_n = _env_int("BAIDU_FACE_MAX_USER_NUM", 1) if args.max_user_num is None else args.max_user_num
|
||||
match_th = _env_int("BAIDU_FACE_MATCH_THRESHOLD", 80) if args.match_threshold is None else args.match_threshold
|
||||
return {
|
||||
"max_user_num": max(1, min(50, max_n)),
|
||||
"match_threshold": max(0, min(100, match_th)),
|
||||
"quality_control": qc,
|
||||
"liveness_control": lc,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_group_id_list(args: argparse.Namespace) -> str:
|
||||
g = (args.group_id_list or "").strip() or _env("BAIDU_FACE_GROUP_ID_LIST")
|
||||
if not g:
|
||||
print(
|
||||
"错误:未指定人脸组。\n"
|
||||
"请设置环境变量 BAIDU_FACE_GROUP_ID_LIST,或传入命令行:--groups a,b",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
return g
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(
|
||||
description="百度人脸 1:N 搜索:在指定人脸库组中,对文件夹内每张照片做相似度检索并打印识别日志。"
|
||||
)
|
||||
p.add_argument(
|
||||
"folder",
|
||||
type=Path,
|
||||
help="含照片的文件夹路径(仅处理 PNG/JPG/JPEG/BMP)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--groups",
|
||||
dest="group_id_list",
|
||||
default=None,
|
||||
help="人脸组 id,逗号分隔,最多 10 个;未传时使用环境变量 BAIDU_FACE_GROUP_ID_LIST",
|
||||
)
|
||||
p.add_argument(
|
||||
"--max-user-num",
|
||||
type=int,
|
||||
default=None,
|
||||
help="覆盖环境变量 BAIDU_FACE_MAX_USER_NUM;返回前 N 个最相似用户(1–50)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--match-threshold",
|
||||
type=int,
|
||||
default=None,
|
||||
help="覆盖环境变量 BAIDU_FACE_MATCH_THRESHOLD;0–100,默认 80",
|
||||
)
|
||||
p.add_argument(
|
||||
"--quality-control",
|
||||
choices=("NONE", "LOW", "NORMAL", "HIGH"),
|
||||
default=None,
|
||||
help="覆盖环境变量 BAIDU_FACE_QUALITY_CONTROL;默认 NONE",
|
||||
)
|
||||
p.add_argument(
|
||||
"--liveness-control",
|
||||
choices=("NONE", "LOW", "NORMAL", "HIGH"),
|
||||
default=None,
|
||||
help="覆盖环境变量 BAIDU_FACE_LIVENESS_CONTROL;默认 NONE",
|
||||
)
|
||||
p.add_argument(
|
||||
"--recursive",
|
||||
action="store_true",
|
||||
help="递归包含子目录中的图片",
|
||||
)
|
||||
p.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="每张照片输出一行 JSON(file + API 原样响应),便于脚本解析",
|
||||
)
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
_load_dotenv_files()
|
||||
args = _parse_args()
|
||||
folder = args.folder.resolve()
|
||||
group_id_list = _resolve_group_id_list(args)
|
||||
_validate_group_id_list(group_id_list)
|
||||
options = _search_options_from_env_and_args(args)
|
||||
|
||||
_group_log = f"[{_ts()}] 使用 group_id_list={group_id_list!r}"
|
||||
if args.json:
|
||||
print(_group_log, file=sys.stderr)
|
||||
else:
|
||||
print(_group_log)
|
||||
|
||||
files = _list_image_files(folder, recursive=args.recursive)
|
||||
if not files:
|
||||
print(
|
||||
f"[{_ts()}] 文件夹内未找到支持格式的图片:{folder}({', '.join(sorted(_IMAGE_SUFFIXES))};可加 --recursive)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
client = _face_client()
|
||||
n = len(files)
|
||||
any_error = False
|
||||
|
||||
for i, path in enumerate(files, start=1):
|
||||
rel = path.name
|
||||
try:
|
||||
b64 = _read_image_base64(path)
|
||||
except (OSError, ValueError) as e:
|
||||
any_error = True
|
||||
print(
|
||||
f"[{_ts()}] [{i}/{n}] 文件 {rel!r} 读取失败:{e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
|
||||
resp = client.search(b64, "BASE64", group_id_list, options)
|
||||
|
||||
if args.json:
|
||||
line = {
|
||||
"file": str(path),
|
||||
"relpath": rel,
|
||||
"index": i,
|
||||
"total": n,
|
||||
"response": resp,
|
||||
}
|
||||
print(json.dumps(line, ensure_ascii=False))
|
||||
if resp.get("error_code", -1) != 0:
|
||||
any_error = True
|
||||
continue
|
||||
|
||||
err = resp.get("error_code")
|
||||
if err != 0:
|
||||
any_error = True
|
||||
msg = resp.get("error_msg", "")
|
||||
print(
|
||||
f"[{_ts()}] [{i}/{n}] {rel!r} 识别失败 error_code={err} error_msg={msg!r}"
|
||||
)
|
||||
continue
|
||||
|
||||
result = resp.get("result") or {}
|
||||
users = result.get("user_list") or []
|
||||
if not users:
|
||||
print(
|
||||
f"[{_ts()}] [{i}/{n}] {rel!r} 无匹配用户 user_list 为空(可检查人脸库或调低匹配阈值)"
|
||||
)
|
||||
continue
|
||||
|
||||
for r, u in enumerate(users, start=1):
|
||||
gid = u.get("group_id", "")
|
||||
uid = u.get("user_id", "")
|
||||
info = u.get("user_info", "")
|
||||
score = u.get("score", "")
|
||||
tag = f" [{r}]" if len(users) > 1 else ""
|
||||
print(
|
||||
f"[{_ts()}] [{i}/{n}] {rel!r} 识别成功{tag} group_id={gid!r} "
|
||||
f"user_id={uid!r} user_info={info!r} score={score}"
|
||||
)
|
||||
|
||||
if any_error:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -698,9 +698,8 @@
|
||||
{ key: "timestamp", label: "time" },
|
||||
{ key: "item_id", label: "item_id" },
|
||||
{ key: "item_name", label: "item_name" },
|
||||
{ key: "quantity", label: "qty" },
|
||||
{ key: "qty", label: "qty" },
|
||||
{ key: "doctor_id", label: "doctor" },
|
||||
{ key: "source", label: "source" },
|
||||
]);
|
||||
renderTable("汇总 summary[]", summary, [
|
||||
{ key: "item_id", label: "item_id" },
|
||||
@@ -710,10 +709,50 @@
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// §4.4 pending-confirmation + 可选 TTS
|
||||
// §4.4 pending-confirmation(响应内带 Base64 MP3)+ 可选自动播报
|
||||
// ============================================================
|
||||
let pollTimer = null;
|
||||
let lastTtsConfirmationId = null;
|
||||
/** 仅在一次成功播出音频/TTS 后更新,避免未播成功却跳过 */
|
||||
let lastSpokenConfirmationId = null;
|
||||
let lastPendingPayload = null;
|
||||
|
||||
/** 方案1:首次用户手势内播放极短静音,解锁自动播放;之后待确认 MP3 复用同一 Audio */
|
||||
const SILENT_UNLOCK_DATA_URL =
|
||||
"data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAE=";
|
||||
let sharedPromptAudio = null;
|
||||
let audioPlaybackUnlocked = false;
|
||||
let lastPromptBlobUrl = null;
|
||||
|
||||
function getSharedPromptAudio() {
|
||||
if (!sharedPromptAudio) {
|
||||
sharedPromptAudio = new Audio();
|
||||
sharedPromptAudio.preload = "auto";
|
||||
sharedPromptAudio.volume = 1;
|
||||
}
|
||||
return sharedPromptAudio;
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
"pointerdown",
|
||||
async () => {
|
||||
if (audioPlaybackUnlocked) return;
|
||||
try {
|
||||
const a = getSharedPromptAudio();
|
||||
if (lastPromptBlobUrl) {
|
||||
URL.revokeObjectURL(lastPromptBlobUrl);
|
||||
lastPromptBlobUrl = null;
|
||||
}
|
||||
a.src = SILENT_UNLOCK_DATA_URL;
|
||||
await a.play();
|
||||
a.pause();
|
||||
a.currentTime = 0;
|
||||
audioPlaybackUnlocked = true;
|
||||
} catch (e) {
|
||||
console.warn("[demo-client] 音频自动播放未解锁(可点「播放话术」)", e);
|
||||
}
|
||||
},
|
||||
{ once: true, capture: true, passive: true },
|
||||
);
|
||||
|
||||
function pickZhTtsVoice() {
|
||||
if (!window.speechSynthesis) return null;
|
||||
@@ -747,40 +786,63 @@
|
||||
});
|
||||
}
|
||||
|
||||
/** 优先 GET /prompt-audio 播放百度 MP3,失败时 speechSynthesis */
|
||||
async function playPromptTts(surgeryId, confirmationId, textFallback) {
|
||||
const path = `/client/surgeries/${surgeryId}/pending-confirmation/${encodeURIComponent(confirmationId)}/prompt-audio`;
|
||||
const u = baseUrl() + path;
|
||||
try {
|
||||
const res = await fetch(u);
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
/** 解码 GET pending 的 prompt_audio_mp3_base64;优先用解锁后的单例 Audio;失败则回退 speechSynthesis */
|
||||
async function playPromptAudioBase64(b64, textFallback) {
|
||||
const t = (textFallback || "").trim();
|
||||
const raw = typeof b64 === "string" ? b64.replace(/\s+/g, "") : "";
|
||||
if (raw) {
|
||||
try {
|
||||
const bin = atob(raw);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
const blob = new Blob([bytes], { type: "audio/mpeg" });
|
||||
const o = URL.createObjectURL(blob);
|
||||
return new Promise((resolve, reject) => {
|
||||
const a = new Audio();
|
||||
a.preload = "auto";
|
||||
a.src = o;
|
||||
a.onended = () => {
|
||||
const a = getSharedPromptAudio();
|
||||
if (lastPromptBlobUrl) {
|
||||
URL.revokeObjectURL(lastPromptBlobUrl);
|
||||
lastPromptBlobUrl = null;
|
||||
}
|
||||
lastPromptBlobUrl = o;
|
||||
a.pause();
|
||||
a.currentTime = 0;
|
||||
a.src = o;
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const cleanupBlob = () => {
|
||||
if (lastPromptBlobUrl === o) {
|
||||
URL.revokeObjectURL(o);
|
||||
lastPromptBlobUrl = null;
|
||||
}
|
||||
};
|
||||
a.onended = () => {
|
||||
cleanupBlob();
|
||||
resolve();
|
||||
};
|
||||
a.onerror = () => {
|
||||
cleanupBlob();
|
||||
reject(new Error("Audio 元素解码/播放失败"));
|
||||
};
|
||||
const p = a.play();
|
||||
if (p && typeof p.catch === "function") {
|
||||
p.catch((err) => {
|
||||
cleanupBlob();
|
||||
reject(err);
|
||||
});
|
||||
}
|
||||
});
|
||||
return;
|
||||
} catch (playErr) {
|
||||
if (lastPromptBlobUrl === o) {
|
||||
URL.revokeObjectURL(o);
|
||||
resolve();
|
||||
};
|
||||
a.onerror = () => {
|
||||
URL.revokeObjectURL(o);
|
||||
reject(new Error("Audio 元素播放失败"));
|
||||
};
|
||||
const p = a.play();
|
||||
if (p && typeof p.catch === "function") {
|
||||
p.catch((err) => {
|
||||
URL.revokeObjectURL(o);
|
||||
reject(err);
|
||||
});
|
||||
lastPromptBlobUrl = null;
|
||||
}
|
||||
});
|
||||
console.warn("[demo-client] MP3 play() 被拒或失败,尝试浏览器朗读", playErr);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[demo-client] Base64 MP3 解码失败,尝试浏览器朗读", e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[demo-client] prompt-audio 不可用,回退浏览器 TTS", e);
|
||||
}
|
||||
return speakTextPromise((textFallback || "").trim());
|
||||
if (t) await speakTextPromise(t);
|
||||
}
|
||||
|
||||
if (window.speechSynthesis) {
|
||||
@@ -788,9 +850,22 @@
|
||||
}
|
||||
|
||||
$("surgery-id").addEventListener("input", () => {
|
||||
lastTtsConfirmationId = null;
|
||||
lastSpokenConfirmationId = null;
|
||||
lastPendingPayload = null;
|
||||
});
|
||||
|
||||
async function playLastPendingManually() {
|
||||
const p = lastPendingPayload;
|
||||
if (!p || !p.confirmation_id) return;
|
||||
const pt = (p.prompt_text || "").trim();
|
||||
try {
|
||||
await playPromptAudioBase64(p.prompt_audio_mp3_base64, pt);
|
||||
lastSpokenConfirmationId = p.confirmation_id;
|
||||
} catch (e) {
|
||||
console.warn("[demo-client] 手动播放失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPendingOnce() {
|
||||
const sid = surgeryId();
|
||||
if (!/^\d{6}$/.test(sid)) return;
|
||||
@@ -818,6 +893,7 @@
|
||||
const box = $("pending-render");
|
||||
if (res.status === 200 && body && body.confirmation_id) {
|
||||
box.hidden = false;
|
||||
lastPendingPayload = body;
|
||||
$("confirmation-id").value = body.confirmation_id;
|
||||
const opts = (body.options || [])
|
||||
.map(o => `<div class="option-row"><span>${o.label}</span><span class="muted">${(o.confidence * 100).toFixed(1)}%</span></div>`)
|
||||
@@ -826,14 +902,27 @@
|
||||
<div><strong>confirmation_id:</strong> <span class="kv">${body.confirmation_id}</span></div>
|
||||
<div style="margin-top:4px"><strong>prompt_text:</strong> ${body.prompt_text || ""}</div>
|
||||
<div style="margin-top:4px"><strong>Top1:</strong> ${body.model_top1_label} <span class="muted">(${(body.model_top1_confidence * 100).toFixed(1)}%)</span></div>
|
||||
<div style="margin-top:6px"><strong>options:</strong>${opts || '<div class="muted">(无)</div>'}</div>`;
|
||||
<div style="margin-top:6px"><strong>options:</strong>${opts || '<div class="muted">(无)</div>'}</div>
|
||||
<div style="margin-top:10px">
|
||||
<button type="button" class="secondary" id="btn-play-pending">▶ 播放话术(MP3 或浏览器朗读)</button>
|
||||
<span class="small muted" style="margin-left:8px">首次在页面任意处点按可解锁自动播报;仍失败时点此处</span>
|
||||
</div>`;
|
||||
const btnPlay = $("btn-play-pending");
|
||||
if (btnPlay) btnPlay.onclick = () => void playLastPendingManually();
|
||||
const pt = (body.prompt_text || "").trim();
|
||||
const ttsOn = $("tts-pending") && $("tts-pending").checked;
|
||||
if (ttsOn && pt && body.confirmation_id !== lastTtsConfirmationId) {
|
||||
lastTtsConfirmationId = body.confirmation_id;
|
||||
void playPromptTts(sid, body.confirmation_id, pt).catch((e) => console.warn(e));
|
||||
if (ttsOn && pt && body.confirmation_id !== lastSpokenConfirmationId) {
|
||||
void (async () => {
|
||||
try {
|
||||
await playPromptAudioBase64(body.prompt_audio_mp3_base64, pt);
|
||||
lastSpokenConfirmationId = body.confirmation_id;
|
||||
} catch (e) {
|
||||
console.warn("[demo-client] 自动播报未完成(可点「播放话术」)", e);
|
||||
}
|
||||
})();
|
||||
}
|
||||
} else if (res.status === 404) {
|
||||
lastPendingPayload = null;
|
||||
box.hidden = false;
|
||||
box.innerHTML = '<span class="muted">暂无待确认项。</span>';
|
||||
} else {
|
||||
@@ -1020,7 +1109,7 @@
|
||||
$("btn-resolve").disabled = true;
|
||||
$("audio-preview").hidden = true;
|
||||
$("btn-download").style.display = "none";
|
||||
lastTtsConfirmationId = null;
|
||||
lastSpokenConfirmationId = null;
|
||||
$("rec-info").textContent = "已提交,正在拉取下一条待确认…";
|
||||
$("rec-info").className = "ok small";
|
||||
await fetchPendingOnce();
|
||||
|
||||
Reference in New Issue
Block a user