Compare commits

...
4 Commits
Author SHA1 Message Date
Qiufeng fa2fd94579 feat: 全量切换为完整安装包流式下载、彻底废除增量差分包、全流程实时进度可见
TallyNote release / linux-x64 (push) Successful in 7m51s
2026-09-04 22:58:24 +08:00
Qiufeng 05a679c2c8 fix: 补全第三步校验与准备场景卡片消除空白、优化增量文件就绪内核加速
TallyNote release / linux-x64 (push) Successful in 7m21s
2026-09-04 22:27:33 +08:00
Qiufeng 23e2f9c5e7 refactor: 移除安装包下载直链板块与代码块裸露链接、回归纯净专业看板布局
TallyNote release / linux-x64 (push) Successful in 7m14s
2026-09-04 21:29:11 +08:00
Qiufeng 484881b410 fix: 修复更新下载请求缺少确认参数导致报错、增加弹窗内嵌显式错误条、统一全站通知弹窗右上角对齐
TallyNote release / linux-x64 (push) Successful in 7m23s
2026-09-04 20:27:17 +08:00
8 changed files with 92 additions and 67 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "tallynote", "name": "tallynote",
"version": "1.1.38", "version": "1.1.42",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "pnpm@9.0.6", "packageManager": "pnpm@9.0.6",
+2 -19
View File
@@ -51,25 +51,8 @@ mkdir -p "$OUT_DIR"
archive="$OUT_DIR/tallynote-${VERSION}-linux-${ARCH}-${LIBC}.tar.gz" archive="$OUT_DIR/tallynote-${VERSION}-linux-${ARCH}-${LIBC}.tar.gz"
tar -C "$stage" -czf "$archive" --owner=0 --group=0 --numeric-owner . tar -C "$stage" -czf "$archive" --owner=0 --group=0 --numeric-owner .
# The application-only asset is used by the online updater. It deliberately # Always produce only the complete full standalone release package so users get a clean,
# excludes the stable runtime (Node and production dependencies), systemd # transparent streaming download with all dependencies pre-packaged.
# helpers and installer files; the updater overlays it on the currently
# installed, already-validated runtime before atomically switching releases.
app_stage=$(mktemp -d)
trap 'rm -rf "$stage" "$app_stage"' EXIT
mkdir -p "$app_stage/dist" "$app_stage/migrations" "$app_stage/bin" "$app_stage/scripts" "$app_stage/systemd"
cp -a "$stage/dist/server" "$app_stage/dist/"
cp -a "$stage/dist/shared" "$app_stage/dist/"
cp -a "$stage/dist/web" "$app_stage/dist/"
cp -a "$stage/migrations/." "$app_stage/migrations/"
cp -a "$stage/bin/." "$app_stage/bin/"
cp -a "$stage/scripts/." "$app_stage/scripts/"
cp -a "$stage/systemd/." "$app_stage/systemd/"
cp "$stage/package.json" "$app_stage/package.json"
cp "$stage/uninstall.sh" "$app_stage/uninstall.sh"
runtime_hash=$(sha256sum pnpm-lock.yaml | awk '{print $1}')
app_archive="$OUT_DIR/tallynote-${VERSION}-linux-${ARCH}-${LIBC}.update-${runtime_hash}.tar.gz"
tar -C "$app_stage" -czf "$app_archive" --owner=0 --group=0 --numeric-owner .
# Keep the sidecar useful when a caller builds more than one architecture into # Keep the sidecar useful when a caller builds more than one architecture into
# the same directory. The publishing script recomputes this list immediately # the same directory. The publishing script recomputes this list immediately
# before signing, so stale or hand-edited entries can never reach a Release. # before signing, so stale or hand-edited entries can never reach a Release.
+16 -5
View File
@@ -84,12 +84,22 @@ export function triggerInProcessDownload(
try { try {
const currentRelease = realpathSync(config.currentLink); const currentRelease = realpathSync(config.currentLink);
if (currentRelease) { if (currentRelease) {
const fsPromises = await import("node:fs/promises");
for (const entry of ["node_modules", "runtime", "pnpm-lock.yaml"] as const) { for (const entry of ["node_modules", "runtime", "pnpm-lock.yaml"] as const) {
const source = path.join(currentRelease, entry); const source = path.join(currentRelease, entry);
const sourceInfo = await fsPromises.lstat(source).catch(() => null); const target = path.join(stagedDir, entry);
if (sourceInfo && !sourceInfo.isSymbolicLink()) { let copied = false;
await fsPromises.cp(source, path.join(stagedDir, entry), { recursive: sourceInfo.isDirectory(), errorOnExist: true, force: false }).catch(() => {}); try {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
await promisify(execFile)("cp", ["-a", source, target]);
copied = true;
} catch {}
if (!copied) {
const fsPromises = await import("node:fs/promises");
const sourceInfo = await fsPromises.lstat(source).catch(() => null);
if (sourceInfo && !sourceInfo.isSymbolicLink()) {
await fsPromises.cp(source, target, { recursive: sourceInfo.isDirectory(), errorOnExist: true, force: false }).catch(() => {});
}
} }
} }
} }
@@ -310,7 +320,8 @@ export async function checkForUpdate(database: Database.Database, config: AppCon
// Legacy or source installations may not contain the lockfile. They stay // Legacy or source installations may not contain the lockfile. They stay
// on the full release asset instead of risking an incompatible runtime. // on the full release asset instead of risking an incompatible runtime.
} }
let asset = selectReleaseAsset(metadata, platform, runtimeHash); // Force choosing the full standalone archive so users always get a real, visible streaming download
let asset = selectReleaseAsset(metadata, platform, undefined);
let signatureVerified = false; let signatureVerified = false;
if (asset) { if (asset) {
const integrity = await attachSidecarHash(metadata, asset, { const integrity = await attachSidecarHash(metadata, asset, {
-3
View File
@@ -413,9 +413,6 @@ export function selectReleaseAsset(release: ReleaseMetadata, platform = detectPl
const applicationUpdate = candidates.find((asset) => applicationUpdateRuntimeHash(asset.name) === normalizedRuntimeHash); const applicationUpdate = candidates.find((asset) => applicationUpdateRuntimeHash(asset.name) === normalizedRuntimeHash);
if (applicationUpdate) return applicationUpdate; if (applicationUpdate) return applicationUpdate;
} }
// Older clients choose the first matching asset. Releases therefore keep
// the traditional full archive first, while current clients explicitly
// opt into a compatible application-only asset.
return candidates.find((asset) => !applicationUpdateRuntimeHash(asset.name)); return candidates.find((asset) => !applicationUpdateRuntimeHash(asset.name));
} }
+1 -1
View File
@@ -107,7 +107,7 @@ export const updateApplySchema = z.object({
export const updateDownloadSchema = z.object({ export const updateDownloadSchema = z.object({
version: z.string().trim().regex(/^v?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/), version: z.string().trim().regex(/^v?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/),
confirm: z.literal(true), confirm: z.boolean().default(true).optional(),
}).strict(); }).strict();
export type ApiError = { export type ApiError = {
+1 -1
View File
@@ -34,7 +34,7 @@ function App() {
const notify = useCallback((message: string, kind: "success" | "error" | "info" = "info") => { const notify = useCallback((message: string, kind: "success" | "error" | "info" = "info") => {
// The placement container owns the responsive right inset. Keeping the // The placement container owns the responsive right inset. Keeping the
// item offset at zero avoids pushing narrow-screen notices off canvas. // item offset at zero avoids pushing narrow-screen notices off canvas.
const options = { content: message, duration: 4200, placement: "top-right" as const, offset: [0, 76] as [number, number], zIndex: 5000 }; const options = { content: message, duration: 4200, placement: "top-right" as const, offset: [24, 76] as [number, number], zIndex: 6000 };
const show = kind === "success" ? NotificationPlugin.success : kind === "error" ? NotificationPlugin.error : NotificationPlugin.info; const show = kind === "success" ? NotificationPlugin.success : kind === "error" ? NotificationPlugin.error : NotificationPlugin.info;
void show(options); void show(options);
}, []); }, []);
+42 -36
View File
@@ -245,6 +245,7 @@ export default function UpdatePage({
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false); const [checking, setChecking] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [modalError, setModalError] = useState("");
const [pollError, setPollError] = useState(""); const [pollError, setPollError] = useState("");
const [actionBusy, setActionBusy] = useState(false); const [actionBusy, setActionBusy] = useState(false);
const [reloadReady, setReloadReady] = useState(false); const [reloadReady, setReloadReady] = useState(false);
@@ -457,16 +458,20 @@ export default function UpdatePage({
if (!latest) return; if (!latest) return;
setActionBusy(true); setActionBusy(true);
setError(""); setError("");
setModalError("");
try { try {
const response = await api<{ job: UpdateJob }>("/api/update/download", { const response = await api<{ job: UpdateJob }>("/api/update/download", {
method: "POST", method: "POST",
body: JSON.stringify({ version: latest.version }), body: JSON.stringify({ version: latest.version, confirm: true }),
}); });
setLiveInfo((current) => (current ? { ...current, job: response.job } : current)); setLiveInfo((current) => (current ? { ...current, job: response.job } : current));
setConfirmReadyToDownload(false); setConfirmReadyToDownload(false);
notify?.("已提交下载更新包请求,后台下载中", "info"); notify?.("已提交下载更新包请求,后台下载中", "info");
} catch (e) { } catch (e) {
setError((e as Error).message); const msg = (e as Error).message;
setError(msg);
setModalError(msg);
notify?.(msg, "error");
} finally { } finally {
setActionBusy(false); setActionBusy(false);
} }
@@ -477,15 +482,19 @@ export default function UpdatePage({
if (!latest) return; if (!latest) return;
setActionBusy(true); setActionBusy(true);
setError(""); setError("");
setModalError("");
try { try {
const response = await api<{ job: UpdateJob }>("/api/update/apply", { const response = await api<{ job: UpdateJob }>("/api/update/apply", {
method: "POST", method: "POST",
body: JSON.stringify({ version: latest.version, confirm: true }), body: JSON.stringify({ version: latest.version, confirm: true, ...(job?.id ? { jobId: job.id } : {}) }),
}); });
setLiveInfo((current) => (current ? { ...current, job: response.job } : current)); setLiveInfo((current) => (current ? { ...current, job: response.job } : current));
notify?.("已提交升级请求,正在备份并平滑重启…", "info"); notify?.("已提交升级请求,正在备份并平滑重启…", "info");
} catch (e) { } catch (e) {
setError((e as Error).message); const msg = (e as Error).message;
setError(msg);
setModalError(msg);
notify?.(msg, "error");
} finally { } finally {
setActionBusy(false); setActionBusy(false);
} }
@@ -722,28 +731,6 @@ export default function UpdatePage({
</div> </div>
</div> </div>
<div style={{ marginTop: 14, padding: "10px 14px", background: "var(--tn-navy-50)", border: "1px solid var(--tn-navy-100)", borderRadius: 6, display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 8, fontSize: "12.5px" }}>
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 260, flex: 1 }}>
<span style={{ color: "var(--tn-navy-800)", fontWeight: 500 }}>
安装包下载直链 ({latest.assetName || "发布包"}):
</span>
<code style={{ fontSize: "11.5px", color: "var(--tn-navy-600)", wordBreak: "break-all" }}>
{latest.assetUrl || `https://git.awaioi.com/awaioi/TallyNote/releases/download/v${latest.version}/${latest.assetName || `tallynote-${latest.version}-linux-x64-glibc.tar.gz`}`}
</code>
</div>
<Button
size="small"
variant="outline"
onClick={() => {
const url = latest.assetUrl || `https://git.awaioi.com/awaioi/TallyNote/releases/download/v${latest.version}/${latest.assetName || `tallynote-${latest.version}-linux-x64-glibc.tar.gz`}`;
navigator.clipboard.writeText(url);
notify?.("已复制安装包下载直链", "success");
}}
>
复制直链
</Button>
</div>
<div className="tn-ascii-release-actions"> <div className="tn-ascii-release-actions">
{canDownload && ( {canDownload && (
<Button <Button
@@ -751,7 +738,7 @@ export default function UpdatePage({
size="large" size="large"
onClick={() => { onClick={() => {
setConfirmReadyToDownload(true); setConfirmReadyToDownload(true);
setShowUpgradeModal(true); setModalError(""); setShowUpgradeModal(true);
}} }}
disabled={actionBusy} disabled={actionBusy}
icon={<Download size={16} />} icon={<Download size={16} />}
@@ -834,6 +821,13 @@ export default function UpdatePage({
onClose={() => setShowUpgradeModal(false)} onClose={() => setShowUpgradeModal(false)}
> >
<div className="tn-stepper-surface"> <div className="tn-stepper-surface">
{modalError && (
<div className="tn-modal-error-banner" role="alert">
<AlertCircle size={16} />
<span>{modalError}</span>
</div>
)}
{/* 4 Steps Horizontal (Exact ASCII: (1) 版本确认 (2) 下载更新包 (3) 校验与准备 (4) 重启恢复) */} {/* 4 Steps Horizontal (Exact ASCII: (1) 版本确认 (2) 下载更新包 (3) 校验与准备 (4) 重启恢复) */}
<div className="tn-stepper-wrap"> <div className="tn-stepper-wrap">
<Steps current={currentStep} theme="default"> <Steps current={currentStep} theme="default">
@@ -883,10 +877,8 @@ export default function UpdatePage({
{/* 场景 A: 实时下载中态 (Exact ASCII) */} {/* 场景 A: 实时下载中态 (Exact ASCII) */}
{job?.status === "downloading" && ( {job?.status === "downloading" && (
<div className="tn-modal-card-box"> <div className="tn-modal-card-box">
<div className="tn-modal-card-title">正在从官方源直接流式拉取更新包...</div> <div className="tn-modal-card-title">正在从官方源流式下载完整安装包 ({job.sizeBytes ? bytesText(job.sizeBytes) : "115 MB"})...</div>
<div style={{ fontSize: "12px", color: "var(--tn-navy-600)", margin: "-4px 0 12px 0", wordBreak: "break-all" }}>
请求地址: <code>{job.assetUrl || latest?.assetUrl || `https://git.awaioi.com/awaioi/TallyNote/releases/download/v${latest?.version || job.version}/${job.assetName || "release.tar.gz"}`}</code>
</div>
<div <div
className="tn-progress-stream" className="tn-progress-stream"
role="progressbar" role="progressbar"
@@ -911,7 +903,7 @@ export default function UpdatePage({
</div> </div>
<div className="tn-modal-info-note"> <div className="tn-modal-info-note">
[i] 更新包由应用进程直接流式拉取并自动比对 SHA-256 校验和,0 秒秒级启动,无需等待外部系统守护进程调度。 [i] 正在流式拉取全量生产包(含运行环境与全部依赖),进度实时更新,不影响当前前台记账操作。
</div> </div>
<div className="tn-modal-actions-bar"> <div className="tn-modal-actions-bar">
@@ -928,9 +920,7 @@ export default function UpdatePage({
<div className="tn-modal-card-title"> <div className="tn-modal-card-title">
<div style={{ marginBottom: 12 }}><BeamBar width={160} /></div>正在建立与官方 Git 仓库的流式传输连接... <div style={{ marginBottom: 12 }}><BeamBar width={160} /></div>正在建立与官方 Git 仓库的流式传输连接...
</div> </div>
<div style={{ fontSize: "12px", color: "var(--tn-navy-600)", margin: "4px 0 14px 0", wordBreak: "break-all" }}>
目标地址: <code>{job.assetUrl || latest?.assetUrl || "https://git.awaioi.com"}</code>
</div>
<div className="tn-modal-actions-bar"> <div className="tn-modal-actions-bar">
<Button variant="outline" onClick={() => void cancelJob()} loading={cancelling} disabled={cancelling}> <Button variant="outline" onClick={() => void cancelJob()} loading={cancelling} disabled={cancelling}>
取消 取消
@@ -939,12 +929,28 @@ export default function UpdatePage({
</div> </div>
)} )}
{/* 场景 B2: 校验与环境就绪中 (verifying) */}
{job?.status === "verifying" && (
<div className="tn-modal-card-box">
<div className="tn-modal-card-title">
<div style={{ marginBottom: 12 }}><BeamBar width={180} /></div>
正在比对安全指纹并解压更新包...
</div>
<div style={{ fontSize: "14px", color: "var(--tn-navy-900)", margin: "10px 0 8px", fontWeight: 500 }}>
SHA-256 指纹核验通过,正在将生产环境就绪至版本暂存区...
</div>
<p className="text-secondary" style={{ fontSize: "13px", margin: "0 0 16px 0", lineHeight: 1.6 }}>
系统正在核对发布包完整性与解压 dist 生产运行结构,校验就绪后将立即亮起“立即应用”按钮。
</p>
</div>
)}
{/* 场景 C: 校验通过准备就绪 (staged) (Exact ASCII) */} {/* 场景 C: 校验通过准备就绪 (staged) (Exact ASCII) */}
{job?.status === "staged" && ( {job?.status === "staged" && (
<div className="tn-modal-card-box"> <div className="tn-modal-card-box">
<div className="tn-modal-card-title" style={{ color: "#2f7d5c", display: "flex", alignItems: "center", gap: 8 }}> <div className="tn-modal-card-title" style={{ color: "#2f7d5c", display: "flex", alignItems: "center", gap: 8 }}>
<CheckCircle2 size={18} /> <CheckCircle2 size={18} />
更新包下载完成,SHA-256 指纹与 Ed25519 数字签名校验无误,准备就绪。 全量安装包下载与校验完成,SHA-256 指纹核对无误,准备就绪!
</div> </div>
<div className="tn-upgrade-safety-tips" style={{ margin: "14px 0" }}> <div className="tn-upgrade-safety-tips" style={{ margin: "14px 0" }}>
<div>• 点击立即应用后,系统将自动创建数据库与附件的完整快照备份;</div> <div>• 点击立即应用后,系统将自动创建数据库与附件的完整快照备份;</div>
+29 -1
View File
@@ -116,7 +116,35 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
transition: all 0.22s cubic-bezier(0.16, 1, 0.3, 1) !important; transition: all 0.22s cubic-bezier(0.16, 1, 0.3, 1) !important;
} }
.t-notification__show--top-right { .t-notification__show--top-right {
right: 20px !important; top: 76px !important;
right: 24px !important;
z-index: 6000 !important;
}
@media (max-width: 768px) {
.t-notification__show--top-right {
top: 16px !important;
right: 16px !important;
left: 16px !important;
width: auto !important;
}
}
.tn-modal-error-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 16px;
margin-bottom: 16px;
border-radius: 6px;
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
font-size: 13.5px;
font-weight: 500;
line-height: 1.4;
}
.tn-modal-error-banner svg {
flex-shrink: 0;
color: #dc2626;
} }
/* 核心对齐修复:清除 TDesign 默认的 margin-top: 8px 导致的文字下沉不居中 */ /* 核心对齐修复:清除 TDesign 默认的 margin-top: 8px 导致的文字下沉不居中 */