- 浏览器预览的掉血脚本写成"打空就重置,然后按 to >= from 判断"——两块板子都打空之后 每一次都是重置,命中再也不出现,盯着界面等闪红会等到超时。现在打空那一拍只重置、 其余每一拍都出命中。 - 设置面板里"偏移存的是**画面比例**"直接写进了 JSX,`**` 原样显示出来了。JSX 不渲染 Markdown,改成 <span class="em">。 顺带把界面截图落到 desktop/screenshots/(不进仓库):这个环境里 Page.captureScreenshot 不可用(空白页也超时),改用 Page.printToPDF + sips 转 PNG;打印路径不渲染 box-shadow 的模糊半径(会变成实心块),所以 .dev/shoot.js 在拍之前把它去掉。
768 lines
25 KiB
TypeScript
768 lines
25 KiB
TypeScript
/**
|
||
* 设置抽屉。两端的"改东西"都收在这里,所以密码闸门也只有一个入口。
|
||
*
|
||
* # 打开面板 ≠ 解锁
|
||
*
|
||
* 面板本身由 `App` 在**解锁之后**才挂载,但面板里的每个改动仍然各自过一次后端闸门
|
||
* (`bind_camera` 等都会校验)。所以即使有人把这里改成"不需要密码",配置也改不动。
|
||
*
|
||
* # 发现列表按需放开
|
||
*
|
||
* 挂载时调 `set_discovery_open(true)`,卸载时关掉。这是用户端唯一会列出别的图传板的
|
||
* 时候——**画面不会**因此进来,接收端那道闸门只管帧,不管发现(见 `cam_core::receiver`)。
|
||
*/
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
|
||
import { api } from "../lib/ipc";
|
||
import { teamName } from "../lib/format";
|
||
import type {
|
||
ConfigView,
|
||
CrosshairConfig,
|
||
HudView,
|
||
Snapshot,
|
||
} from "../lib/types";
|
||
import { Button, Field, Section, Select, Slider, Switch } from "./controls";
|
||
|
||
interface Props {
|
||
snapshot: Snapshot;
|
||
hud: HudView;
|
||
onHud: (hud: HudView) => void;
|
||
calibrating: boolean;
|
||
onCalibrating: (active: boolean) => void;
|
||
onClose: () => void;
|
||
onNeedsUnlock: (reason: string) => void;
|
||
onError: (message: string) => void;
|
||
onInfo: (message: string) => void;
|
||
/** 配置变了(身份/绑定)。App 用它立刻刷新顶层状态。 */
|
||
onConfig: (config: ConfigView) => void;
|
||
}
|
||
|
||
/** HUD 的滑杆每动一下都会触发保存,节流一下,免得写盘几十次。 */
|
||
const SAVE_DEBOUNCE_MS = 350;
|
||
|
||
export function SettingsPanel({
|
||
snapshot,
|
||
hud,
|
||
onHud,
|
||
calibrating,
|
||
onCalibrating,
|
||
onClose,
|
||
onNeedsUnlock,
|
||
onError,
|
||
onInfo,
|
||
onConfig,
|
||
}: Props) {
|
||
const config = snapshot.config;
|
||
const locked = !snapshot.unlocked && config.role !== "referee";
|
||
|
||
// 打开面板时放开发现列表(后端会校验是否已解锁)。
|
||
useEffect(() => {
|
||
if (locked) return;
|
||
|
||
void api.setDiscoveryOpen(true).catch((err) => {
|
||
onNeedsUnlock(err instanceof Error ? err.message : String(err));
|
||
});
|
||
|
||
return () => {
|
||
void api.setDiscoveryOpen(false).catch(() => undefined);
|
||
};
|
||
}, [locked, onNeedsUnlock]);
|
||
|
||
// HUD 的保存节流。滑杆一停就写。
|
||
const [pending, setPending] = useState<HudView | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!pending) return;
|
||
|
||
const timer = window.setTimeout(() => {
|
||
setPending(null);
|
||
void api.saveHud(pending).then(onConfig).catch((err) => {
|
||
onError(err instanceof Error ? err.message : String(err));
|
||
});
|
||
}, SAVE_DEBOUNCE_MS);
|
||
|
||
return () => window.clearTimeout(timer);
|
||
}, [pending, onConfig, onError]);
|
||
|
||
const update = useCallback(
|
||
(change: (draft: HudView) => void) => {
|
||
const next = structuredClone(hud);
|
||
change(next);
|
||
onHud(next);
|
||
setPending(next);
|
||
},
|
||
[hud, onHud],
|
||
);
|
||
|
||
const run = async (action: () => Promise<ConfigView>) => {
|
||
try {
|
||
onConfig(await action());
|
||
await api.refresh();
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err);
|
||
|
||
if (message.includes("密码")) {
|
||
onNeedsUnlock(message);
|
||
return;
|
||
}
|
||
|
||
onError(message);
|
||
}
|
||
};
|
||
|
||
const cameraOptions = useMemo(
|
||
() =>
|
||
snapshot.cameras.map((camera) => ({
|
||
value: camera.id,
|
||
label: camera.idText,
|
||
hint: `${camera.addr} · ${camera.online ? "在线" : "离线"}${
|
||
snapshot.stats.find((s) => s.camera === camera.id)?.hasFrames ? "" : " · 无画面"
|
||
}`,
|
||
})),
|
||
[snapshot.cameras, snapshot.stats],
|
||
);
|
||
|
||
const armorOptions = useMemo(
|
||
() =>
|
||
snapshot.armorBoards.map((board) => ({
|
||
value: board.id,
|
||
label: board.idText,
|
||
hint: `${board.addr} · ${teamName(board.team)} · ${board.hp} 血 · ${
|
||
board.online ? "在线" : "离线"
|
||
}`,
|
||
})),
|
||
[snapshot.armorBoards],
|
||
);
|
||
|
||
return (
|
||
<aside className="drawer">
|
||
<header className="drawer__head">
|
||
<h2 className="drawer__title">设置</h2>
|
||
<div className="drawer__head-actions">
|
||
{config.role === "team" && (
|
||
<Button
|
||
variant="ghost"
|
||
onClick={() => {
|
||
void api.lock().then(() => {
|
||
onInfo("已重新上锁。");
|
||
onClose();
|
||
});
|
||
}}
|
||
>
|
||
上锁
|
||
</Button>
|
||
)}
|
||
<Button variant="ghost" onClick={onClose}>
|
||
关闭
|
||
</Button>
|
||
</div>
|
||
</header>
|
||
|
||
{locked && (
|
||
<p className="drawer__locked">
|
||
这个面板的改动需要密码。<button onClick={() => onNeedsUnlock("改动设置需要密码")}>立即解锁</button>
|
||
</p>
|
||
)}
|
||
|
||
<div className="drawer__body">
|
||
<Section title="身份">
|
||
<Field label="这台机器是哪一端">
|
||
<Select
|
||
value={config.role}
|
||
options={[
|
||
{ value: "team", label: "用户端", hint: "一路画面 + 血条 + 瞄准器" },
|
||
{ value: "referee", label: "裁判端", hint: "两路 + 双方血量 + 录制" },
|
||
]}
|
||
onChange={(role) => void run(() => api.setRole(role))}
|
||
/>
|
||
</Field>
|
||
<p className="section__note">
|
||
离开用户端要密码:否则切到裁判端就能看两路画面、也能随便改配置。
|
||
</p>
|
||
</Section>
|
||
|
||
{config.role === "team" && (
|
||
<>
|
||
<Section
|
||
title="绑定"
|
||
hint="图传板与装甲板都用 MAC 派生的 id 标识,重启不变,所以绑定是稳的。改动需要密码。"
|
||
>
|
||
<Field label="摄像头(图传板)">
|
||
<Select
|
||
value={config.cameraId}
|
||
options={cameraOptions}
|
||
onChange={(id) => void run(() => api.bindCamera(id))}
|
||
/>
|
||
</Field>
|
||
|
||
<Field label="装甲板主板" hint="决定血条读谁的血量。下拉里是场上正在广播的板子。">
|
||
<Select
|
||
value={config.armorId}
|
||
options={armorOptions}
|
||
onChange={(id) => void run(() => api.bindArmor(id))}
|
||
/>
|
||
</Field>
|
||
|
||
<Field label="自己属于哪一边" hint="改错队号会让血条显示对手的血量。">
|
||
<Select
|
||
value={config.team}
|
||
options={[
|
||
{ value: 0, label: "队伍一(红)" },
|
||
{ value: 1, label: "队伍二(蓝)" },
|
||
]}
|
||
onChange={(team) => void run(() => api.setTeam(team))}
|
||
/>
|
||
</Field>
|
||
|
||
<Switch
|
||
checked={config.mirror}
|
||
label="左右镜像画面(摄像头装反了)"
|
||
onChange={(mirror) => void run(() => api.setMirror(mirror))}
|
||
/>
|
||
|
||
{cameraOptions.length === 0 && (
|
||
<p className="section__note section__note--warn">
|
||
现在没有发现任何图传板。板子每 2 秒广播一次,确认它已上电、和这台机器在同一个
|
||
网络里,然后稍等。
|
||
</p>
|
||
)}
|
||
</Section>
|
||
|
||
<Section title="瞄准器" hint="位置跟着画面走,大小不跟着缩放走——放大后准星更精确。">
|
||
<Switch
|
||
checked={hud.crosshair.visible}
|
||
label="显示瞄准器"
|
||
onChange={(visible) =>
|
||
update((draft) => {
|
||
draft.crosshair.visible = visible;
|
||
})
|
||
}
|
||
/>
|
||
|
||
<Field label="样式">
|
||
<Select
|
||
value={hud.crosshair.style}
|
||
options={[
|
||
{ value: "cross", label: "四臂十字" },
|
||
{ value: "cross-dot", label: "十字 + 中心点" },
|
||
{ value: "circle-dot", label: "圆环 + 中心点" },
|
||
{ value: "dot", label: "只有点" },
|
||
]}
|
||
onChange={(style) =>
|
||
update((draft) => {
|
||
draft.crosshair.style = style;
|
||
})
|
||
}
|
||
/>
|
||
</Field>
|
||
|
||
<Field label="大小" hint="画面短边的比例">
|
||
<Slider
|
||
value={hud.crosshair.size}
|
||
min={0.01}
|
||
max={0.2}
|
||
step={0.005}
|
||
format={(value) => `${(value * 100).toFixed(1)}%`}
|
||
onChange={(size) =>
|
||
update((draft) => {
|
||
draft.crosshair.size = size;
|
||
})
|
||
}
|
||
/>
|
||
</Field>
|
||
|
||
<Field label="线宽">
|
||
<Slider
|
||
value={hud.crosshair.thickness}
|
||
min={0.001}
|
||
max={0.02}
|
||
step={0.0005}
|
||
format={(value) => value.toFixed(4)}
|
||
onChange={(thickness) =>
|
||
update((draft) => {
|
||
draft.crosshair.thickness = thickness;
|
||
})
|
||
}
|
||
/>
|
||
</Field>
|
||
|
||
<Field label="中心空隙">
|
||
<Slider
|
||
value={hud.crosshair.gap}
|
||
min={0}
|
||
max={0.1}
|
||
step={0.002}
|
||
format={(value) => `${(value * 100).toFixed(1)}%`}
|
||
onChange={(gap) =>
|
||
update((draft) => {
|
||
draft.crosshair.gap = gap;
|
||
})
|
||
}
|
||
/>
|
||
</Field>
|
||
|
||
<Field label="颜色">
|
||
<input
|
||
className="color"
|
||
type="color"
|
||
value={hud.crosshair.color}
|
||
onChange={(event) =>
|
||
update((draft) => {
|
||
draft.crosshair.color = event.target.value;
|
||
})
|
||
}
|
||
/>
|
||
</Field>
|
||
|
||
<Calibration
|
||
crosshair={hud.crosshair}
|
||
calibrating={calibrating}
|
||
onCalibrating={onCalibrating}
|
||
onChange={(offsetX, offsetY) =>
|
||
update((draft) => {
|
||
draft.crosshair.offsetX = offsetX;
|
||
draft.crosshair.offsetY = offsetY;
|
||
})
|
||
}
|
||
/>
|
||
</Section>
|
||
|
||
<Section title="叠加与提示">
|
||
<Switch
|
||
checked={hud.grid.visible}
|
||
label="显示网格"
|
||
onChange={(visible) =>
|
||
update((draft) => {
|
||
draft.grid.visible = visible;
|
||
})
|
||
}
|
||
/>
|
||
|
||
{hud.grid.visible && (
|
||
<>
|
||
<Field label="网格样式">
|
||
<Select
|
||
value={hud.grid.style}
|
||
options={[
|
||
{ value: "thirds", label: "三分线" },
|
||
{ value: "squares", label: "等分方格" },
|
||
{ value: "cross", label: "中心十字" },
|
||
]}
|
||
onChange={(style) =>
|
||
update((draft) => {
|
||
draft.grid.style = style;
|
||
})
|
||
}
|
||
/>
|
||
</Field>
|
||
|
||
{hud.grid.style === "squares" && (
|
||
<Field label="等分数">
|
||
<Slider
|
||
value={hud.grid.divisions}
|
||
min={2}
|
||
max={16}
|
||
step={1}
|
||
format={(value) => `${value} 格`}
|
||
onChange={(divisions) =>
|
||
update((draft) => {
|
||
draft.grid.divisions = divisions;
|
||
})
|
||
}
|
||
/>
|
||
</Field>
|
||
)}
|
||
|
||
<Field label="网格明显程度">
|
||
<Slider
|
||
value={hud.grid.opacity}
|
||
min={0.05}
|
||
max={1}
|
||
step={0.01}
|
||
format={(value) => value.toFixed(2)}
|
||
onChange={(opacity) =>
|
||
update((draft) => {
|
||
draft.grid.opacity = opacity;
|
||
})
|
||
}
|
||
/>
|
||
</Field>
|
||
</>
|
||
)}
|
||
|
||
<Switch
|
||
checked={hud.showZoom}
|
||
label="显示放大倍率"
|
||
onChange={(showZoom) =>
|
||
update((draft) => {
|
||
draft.showZoom = showZoom;
|
||
})
|
||
}
|
||
/>
|
||
|
||
<Switch
|
||
checked={hud.hitFeedback.flash}
|
||
label="被打中时闪红"
|
||
onChange={(flash) =>
|
||
update((draft) => {
|
||
draft.hitFeedback.flash = flash;
|
||
})
|
||
}
|
||
/>
|
||
|
||
<Switch
|
||
checked={hud.hitFeedback.shake}
|
||
label="被打中时画面抖动"
|
||
onChange={(shake) =>
|
||
update((draft) => {
|
||
draft.hitFeedback.shake = shake;
|
||
})
|
||
}
|
||
/>
|
||
|
||
<Switch
|
||
checked={hud.hitFeedback.marker}
|
||
label="在瞄点旁显示伤害数字"
|
||
onChange={(marker) =>
|
||
update((draft) => {
|
||
draft.hitFeedback.marker = marker;
|
||
})
|
||
}
|
||
/>
|
||
|
||
<Field label="反馈强度">
|
||
<Slider
|
||
value={hud.hitFeedback.intensity}
|
||
min={0.2}
|
||
max={2}
|
||
step={0.1}
|
||
format={(value) => `${value.toFixed(1)}×`}
|
||
onChange={(intensity) =>
|
||
update((draft) => {
|
||
draft.hitFeedback.intensity = intensity;
|
||
})
|
||
}
|
||
/>
|
||
</Field>
|
||
</Section>
|
||
|
||
<Section title="血条">
|
||
<Switch
|
||
checked={hud.health.visible}
|
||
label="显示血条"
|
||
onChange={(visible) =>
|
||
update((draft) => {
|
||
draft.health.visible = visible;
|
||
})
|
||
}
|
||
/>
|
||
<Switch
|
||
checked={hud.health.showNumbers}
|
||
label="显示血量数字"
|
||
onChange={(showNumbers) =>
|
||
update((draft) => {
|
||
draft.health.showNumbers = showNumbers;
|
||
})
|
||
}
|
||
/>
|
||
<Switch
|
||
checked={hud.health.showTeamName}
|
||
label="显示队伍名"
|
||
onChange={(showTeamName) =>
|
||
update((draft) => {
|
||
draft.health.showTeamName = showTeamName;
|
||
})
|
||
}
|
||
/>
|
||
<Switch
|
||
checked={hud.health.pulse}
|
||
label="低血时脉动"
|
||
onChange={(pulse) =>
|
||
update((draft) => {
|
||
draft.health.pulse = pulse;
|
||
})
|
||
}
|
||
/>
|
||
<Field label="低血阈值">
|
||
<Slider
|
||
value={hud.health.lowThreshold}
|
||
min={5}
|
||
max={100}
|
||
step={5}
|
||
format={(value) => `${value} 血`}
|
||
onChange={(lowThreshold) =>
|
||
update((draft) => {
|
||
draft.health.lowThreshold = lowThreshold;
|
||
})
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field label="血条宽度" hint="占画面宽度的比例">
|
||
<Slider
|
||
value={hud.health.width}
|
||
min={0.15}
|
||
max={0.9}
|
||
step={0.01}
|
||
format={(value) => `${(value * 100).toFixed(0)}%`}
|
||
onChange={(width) =>
|
||
update((draft) => {
|
||
draft.health.width = width;
|
||
})
|
||
}
|
||
/>
|
||
</Field>
|
||
</Section>
|
||
</>
|
||
)}
|
||
|
||
{config.role === "referee" && (
|
||
<Section
|
||
title="裁判端槽位"
|
||
hint="默认按装甲板自报的队伍自动分左右。有烧错固件的板子时打开手动指定。"
|
||
>
|
||
<Switch
|
||
checked={snapshot.slots.manual}
|
||
label="手动指定槽位"
|
||
onChange={(manual) =>
|
||
void run(() =>
|
||
api.setRefereeSlots({
|
||
cameraA: snapshot.slots.cameraA,
|
||
cameraB: snapshot.slots.cameraB,
|
||
armorA: snapshot.slots.armorA,
|
||
armorB: snapshot.slots.armorB,
|
||
manual,
|
||
}),
|
||
)
|
||
}
|
||
/>
|
||
|
||
{snapshot.slots.manual && (
|
||
<>
|
||
<Field label="左格:队伍一摄像头">
|
||
<Select
|
||
value={snapshot.slots.cameraA}
|
||
options={cameraOptions}
|
||
onChange={(cameraA) =>
|
||
void run(() =>
|
||
api.setRefereeSlots({ ...snapshot.slots, cameraA, manual: true }),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field label="左格:队伍一装甲板">
|
||
<Select
|
||
value={snapshot.slots.armorA}
|
||
options={armorOptions}
|
||
onChange={(armorA) =>
|
||
void run(() =>
|
||
api.setRefereeSlots({ ...snapshot.slots, armorA, manual: true }),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field label="右格:队伍二摄像头">
|
||
<Select
|
||
value={snapshot.slots.cameraB}
|
||
options={cameraOptions}
|
||
onChange={(cameraB) =>
|
||
void run(() =>
|
||
api.setRefereeSlots({ ...snapshot.slots, cameraB, manual: true }),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field label="右格:队伍二装甲板">
|
||
<Select
|
||
value={snapshot.slots.armorB}
|
||
options={armorOptions}
|
||
onChange={(armorB) =>
|
||
void run(() =>
|
||
api.setRefereeSlots({ ...snapshot.slots, armorB, manual: true }),
|
||
)
|
||
}
|
||
/>
|
||
</Field>
|
||
</>
|
||
)}
|
||
</Section>
|
||
)}
|
||
|
||
<Section title="录制与截图" hint="截图存 PNG,录制存 mp4(需要 ffmpeg)。">
|
||
<Field label="输出目录">
|
||
<input
|
||
className="input"
|
||
type="text"
|
||
defaultValue={config.recordingDir}
|
||
onBlur={(event) => {
|
||
const value = event.target.value.trim();
|
||
if (value !== config.recordingDir) {
|
||
void run(() => api.setRecordingOptions({ dir: value, ffmpeg: null, crf: null, preset: null }));
|
||
}
|
||
}}
|
||
/>
|
||
</Field>
|
||
<Field label="ffmpeg 路径" hint="默认从 PATH 找。找不到就录不了,但界面照常用。">
|
||
<input
|
||
className="input"
|
||
type="text"
|
||
defaultValue={config.ffmpeg}
|
||
onBlur={(event) => {
|
||
const value = event.target.value.trim();
|
||
if (value && value !== config.ffmpeg) {
|
||
void run(() => api.setRecordingOptions({ dir: null, ffmpeg: value, crf: null, preset: null }));
|
||
}
|
||
}}
|
||
/>
|
||
</Field>
|
||
<p className="section__note">
|
||
界面内回放要目录在资源协议的白名单里(默认的 ~/Movies/cam-desktop 就在)。
|
||
改到别处时回放会失败,界面上会给出"用系统播放器打开"的退路。
|
||
</p>
|
||
</Section>
|
||
|
||
<Section title="安全" hint="密码只挡现场随手改绑;配置文件与程序都在本机,愿意改的人绕得过去。">
|
||
<ChangePassword onDone={onInfo} onError={onError} />
|
||
<div className="row">
|
||
<Button
|
||
variant="danger"
|
||
onClick={() => {
|
||
if (!window.confirm("清空配置?旧文件会被改名成 .bak,然后需要重启程序。")) return;
|
||
void api.clearConfig().then(
|
||
(message) => onInfo(message),
|
||
(err) => {
|
||
const text = err instanceof Error ? err.message : String(err);
|
||
if (text.includes("密码")) onNeedsUnlock(text);
|
||
else onError(text);
|
||
},
|
||
);
|
||
}}
|
||
>
|
||
清空配置
|
||
</Button>
|
||
|
||
<Button
|
||
variant="danger"
|
||
onClick={() => {
|
||
if (!window.confirm("退出程序?正在进行的录制会先收尾。")) return;
|
||
void api.quit().catch((err) => {
|
||
const text = err instanceof Error ? err.message : String(err);
|
||
if (text.includes("密码")) onNeedsUnlock(text);
|
||
else onError(text);
|
||
});
|
||
}}
|
||
>
|
||
退出程序
|
||
</Button>
|
||
</div>
|
||
</Section>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
/** 瞄准器偏移:拖画面校准 + 数值微调 + 归零。 */
|
||
function Calibration({
|
||
crosshair,
|
||
calibrating,
|
||
onCalibrating,
|
||
onChange,
|
||
}: {
|
||
crosshair: CrosshairConfig;
|
||
calibrating: boolean;
|
||
onCalibrating: (active: boolean) => void;
|
||
onChange: (offsetX: number, offsetY: number) => void;
|
||
}) {
|
||
return (
|
||
<div className="calibration">
|
||
<Switch
|
||
checked={calibrating}
|
||
label="拖动画面校准瞄准点"
|
||
onChange={onCalibrating}
|
||
/>
|
||
|
||
<p className="section__note">
|
||
摄像头与枪管不同轴时用这里把瞄点挪到弹着点上。偏移存的是<span className="em">画面比例</span>
|
||
,所以换分辨率、换窗口大小都不影响它。
|
||
</p>
|
||
|
||
<Field label="水平偏移" hint={`${(crosshair.offsetX * 100).toFixed(2)}%`}>
|
||
<Slider
|
||
value={crosshair.offsetX}
|
||
min={-0.3}
|
||
max={0.3}
|
||
step={0.001}
|
||
format={(value) => `${(value * 100).toFixed(2)}%`}
|
||
onChange={(value) => onChange(value, crosshair.offsetY)}
|
||
/>
|
||
</Field>
|
||
|
||
<Field label="垂直偏移" hint={`${(crosshair.offsetY * 100).toFixed(2)}%`}>
|
||
<Slider
|
||
value={crosshair.offsetY}
|
||
min={-0.3}
|
||
max={0.3}
|
||
step={0.001}
|
||
format={(value) => `${(value * 100).toFixed(2)}%`}
|
||
onChange={(value) => onChange(crosshair.offsetX, value)}
|
||
/>
|
||
</Field>
|
||
|
||
<Button variant="ghost" onClick={() => onChange(0, 0)}>
|
||
偏移归零
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 改密码。 */
|
||
function ChangePassword({
|
||
onDone,
|
||
onError,
|
||
}: {
|
||
onDone: (message: string) => void;
|
||
onError: (message: string) => void;
|
||
}) {
|
||
const [current, setCurrent] = useState("");
|
||
const [fresh, setFresh] = useState("");
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
return (
|
||
<div className="password-change">
|
||
<input
|
||
className="input"
|
||
type="password"
|
||
placeholder="当前密码"
|
||
autoComplete="off"
|
||
value={current}
|
||
onChange={(event) => setCurrent(event.target.value)}
|
||
/>
|
||
<input
|
||
className="input"
|
||
type="password"
|
||
placeholder="新密码(≥4 位)"
|
||
autoComplete="new-password"
|
||
value={fresh}
|
||
onChange={(event) => setFresh(event.target.value)}
|
||
/>
|
||
<Button
|
||
disabled={busy || fresh.length < 4}
|
||
onClick={() => {
|
||
setBusy(true);
|
||
void api
|
||
.setPassword(fresh, current)
|
||
.then(() => {
|
||
onDone("密码已更新。");
|
||
setCurrent("");
|
||
setFresh("");
|
||
})
|
||
.catch((err) => onError(err instanceof Error ? err.message : String(err)))
|
||
.finally(() => setBusy(false));
|
||
}}
|
||
>
|
||
{busy ? "保存中…" : "修改密码"}
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|