fix(sync): 跨工作区重启保留重试与暂停状态
This commit is contained in:
@@ -108,3 +108,11 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
|
||||
- 设置编辑先保存按 Vault 隔离的持久草稿,再通过 CAS 写入 Workspace。覆盖选择串行等待在途写入,选择远端期间的新编辑不被丢弃;存储删除失败时保留内存及磁盘草稿并报告失败。远端主题应用不产生同步回流。
|
||||
- 初始上传和合并预览包含当前偏好;空库下载不提前创建记录。真实双客户端 HTTP 测试新增主题记录传播,嵌套未知字段和非法值由 Rust 测试验证。
|
||||
- 本增量全量前端 97 文件 / 515 项通过;其后补充存储故障修复,两文件 7 项针对性测试通过。TypeScript 两项目检查通过,Rust desktop 全目标通过(数量见测试日志),Clippy `-D warnings` 通过。测试日志为 `.build/preferences-frontend-tests.log` 和 `.build/preferences-rust-tests.log`。生产签名、OS 沙箱、Skill/配置等未完成项仍未通过整体验收。
|
||||
|
||||
|
||||
## 增量:跨重启的同步退避和停止状态
|
||||
|
||||
- Workspace schema 9 持久保存每个绑定的错误码、失败次数、下次重试时间及 halted 状态。旧 schema 升级前保留独立 SQLite 备份。重启后自动同步沿用该状态;成功、同账户登录恢复或手动继续会清除当前绑定状态。
|
||||
- 取消/切换绑定不增加失败次数,旧绑定回调不能修改新绑定;保存的错误仅允许有长度限制的机器码,不保存服务端任意响应正文。Retry-After 和指数退避保持 1–3600 秒限制。界面对停止自动重试状态不再显示误导性的倒计时。
|
||||
- 连续关闭重开 20 次验证失败次数与时间保留,另验证认证停止、状态清除、取消、错误正文过滤和新旧绑定隔离。当前是绑定级轮次状态;每个上传作业的独立尝试历史、强制刷新后的 401 重试和完整服务故障矩阵仍需完成。
|
||||
- 全量前端 97 文件 / 517 项、Rust desktop 全目标 52 项通过(另 1 个父测试驱动并强杀的辅助入口);TypeScript 两项目检查和 Clippy `-D warnings` 通过。日志 `.build/retry-frontend-tests.log`、`.build/retry-rust-tests.log`。Core 打包正在更新,不视为签名发布包。
|
||||
|
||||
@@ -22,3 +22,5 @@ pub mod sync_resolution;
|
||||
pub mod sync_state;
|
||||
pub mod workspace;
|
||||
pub mod workspace_broker;
|
||||
|
||||
pub mod sync_retry;
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
Mutex,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tauri::State;
|
||||
use zeroize::Zeroizing;
|
||||
@@ -26,10 +26,6 @@ pub struct Runtime {
|
||||
#[derive(Default)]
|
||||
struct Progress {
|
||||
running: bool,
|
||||
error: Option<String>,
|
||||
failures: u32,
|
||||
retry: Option<Instant>,
|
||||
halted: bool,
|
||||
}
|
||||
impl Runtime {
|
||||
pub fn cancel(&self) {
|
||||
@@ -63,6 +59,21 @@ pub async fn sync_login(host: State<'_, Host>, request: Login) -> Result<Connect
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.code)?;
|
||||
with_workspace(&host, |ws| {
|
||||
if let Some(binding) = ws.sync_binding()? {
|
||||
if binding.endpoint == endpoint && binding.account == request.account {
|
||||
ws.sync_retry_clear(&binding.id)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.or_else(|error| {
|
||||
if error == "VAULT_NOT_OPEN" {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(error)
|
||||
}
|
||||
})?;
|
||||
host.sync.status.lock().map_err(|_| "HOST_BUSY")?.clear();
|
||||
Ok(Connection {
|
||||
endpoint,
|
||||
@@ -223,7 +234,13 @@ pub fn sync_unbind(host: State<'_, Host>, binding_id: String) -> Result<(), Stri
|
||||
#[tauri::command]
|
||||
pub fn sync_pause(host: State<'_, Host>, binding_id: String, paused: bool) -> Result<(), String> {
|
||||
host.sync.cancel();
|
||||
with_workspace(&host, |ws| ws.sync_pause(&binding_id, paused))?;
|
||||
with_workspace(&host, |ws| {
|
||||
ws.sync_pause(&binding_id, paused)?;
|
||||
if !paused {
|
||||
ws.sync_retry_clear(&binding_id)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
host.sync
|
||||
.status
|
||||
.lock()
|
||||
@@ -247,13 +264,18 @@ pub fn sync_status(host: State<'_, Host>) -> Result<Value, String> {
|
||||
.unwrap_or_default();
|
||||
Ok((
|
||||
ws.vault_id.clone(),
|
||||
binding,
|
||||
binding.clone(),
|
||||
paused,
|
||||
conflicts,
|
||||
ws.pending_count()?,
|
||||
binding
|
||||
.as_ref()
|
||||
.map(|b| ws.sync_retry(&b.id))
|
||||
.transpose()?
|
||||
.unwrap_or_default(),
|
||||
))
|
||||
})?;
|
||||
let (vault_id, binding, paused, conflicts, pending) = snapshot;
|
||||
let (vault_id, binding, paused, conflicts, pending, retry) = snapshot;
|
||||
let credential_state = if let Some(b) = &binding {
|
||||
sync_auth::available(&host.credentials, &b.endpoint, &b.account)
|
||||
.map(|exists| {
|
||||
@@ -271,7 +293,7 @@ pub fn sync_status(host: State<'_, Host>) -> Result<Value, String> {
|
||||
let statuses = host.sync.status.lock().map_err(|_| "HOST_BUSY")?;
|
||||
let status = binding.as_ref().and_then(|b| statuses.get(&b.id));
|
||||
Ok(
|
||||
json!({"vault_id":vault_id,"binding":binding,"paused":paused,"pending":pending,"conflicts":conflicts,"credential_state":credential_state,"running":status.is_some_and(|s|s.running),"error":status.and_then(|s|s.error.as_ref()),"retry_in":status.and_then(|s|s.retry).map(|time|time.saturating_duration_since(Instant::now()).as_secs())}),
|
||||
json!({"vault_id":vault_id,"binding":binding,"paused":paused,"pending":pending,"conflicts":conflicts,"credential_state":credential_state,"running":status.is_some_and(|s|s.running),"error":retry.error,"retry_in":retry.retry_at.map(|_| retry.remaining(now())),"failures":retry.failures,"halted":retry.halted}),
|
||||
)
|
||||
}
|
||||
#[tauri::command]
|
||||
@@ -315,49 +337,54 @@ pub async fn run(host: &Host, manual: bool) -> Result<(), String> {
|
||||
if with_workspace(host, |ws| ws.sync_paused(&binding.id))? {
|
||||
return Err("SYNC_PAUSED".into());
|
||||
}
|
||||
{
|
||||
let mut statuses = host.sync.status.lock().map_err(|_| "HOST_BUSY")?;
|
||||
let state = statuses.entry(binding.id.clone()).or_default();
|
||||
if !manual && (state.halted || state.retry.is_some_and(|v| v > Instant::now())) {
|
||||
let retry = with_workspace(host, |ws| ws.sync_retry(&binding.id))?;
|
||||
if !manual && (retry.halted || retry.remaining(now()) > 0) {
|
||||
return Ok(());
|
||||
}
|
||||
state.running = true;
|
||||
state.error = None;
|
||||
}
|
||||
host.sync
|
||||
.status
|
||||
.lock()
|
||||
.map_err(|_| "HOST_BUSY")?
|
||||
.entry(binding.id.clone())
|
||||
.or_default()
|
||||
.running = true;
|
||||
let result = cycle(host, &binding).await;
|
||||
let mut statuses = host.sync.status.lock().map_err(|_| "HOST_BUSY")?;
|
||||
let state = statuses.entry(binding.id.clone()).or_default();
|
||||
state.running = false;
|
||||
host.sync
|
||||
.status
|
||||
.lock()
|
||||
.map_err(|_| "HOST_BUSY")?
|
||||
.entry(binding.id.clone())
|
||||
.or_default()
|
||||
.running = false;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
*state = Progress::default();
|
||||
Ok(())
|
||||
}
|
||||
Ok(()) => with_workspace(host, |ws| ws.sync_retry_clear(&binding.id)),
|
||||
Err(error) => {
|
||||
state.failures = if error.code == "CREDENTIALS_LOCKED" {
|
||||
0
|
||||
} else {
|
||||
state.failures.saturating_add(1)
|
||||
};
|
||||
state.error = Some(error.code.clone());
|
||||
state.halted = matches!(error.status, 401 | 403 | 413 | 426 | 507)
|
||||
|| matches!(
|
||||
if !matches!(
|
||||
error.code.as_str(),
|
||||
"PROTOCOL_INCOMPATIBLE" | "SYNC_LOGIN_REQUIRED"
|
||||
);
|
||||
state.retry = Some(
|
||||
Instant::now()
|
||||
+ Duration::from_secs(
|
||||
error
|
||||
.retry_after
|
||||
.unwrap_or(2u64.saturating_pow(state.failures.min(8)))
|
||||
.clamp(1, 3600),
|
||||
),
|
||||
);
|
||||
"SYNC_CANCELLED" | "SYNC_BINDING_CHANGED" | "VAULT_CHANGED"
|
||||
) {
|
||||
with_workspace(host, |ws| {
|
||||
ws.sync_retry_fail(
|
||||
&binding.id,
|
||||
&error.code,
|
||||
error.status,
|
||||
error.retry_after,
|
||||
now(),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Err(error.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn now() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
.min(i64::MAX as u64) as i64
|
||||
}
|
||||
|
||||
async fn cycle(host: &Host, binding: &Binding) -> Result<(), SyncError> {
|
||||
let epoch = host.sync.epoch.load(Ordering::SeqCst);
|
||||
let work = async {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Binding-scoped retry decisions survive restart; wall time is bounded after clock changes.
|
||||
use crate::workspace::{Result, Workspace};
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Default, Debug, Serialize)]
|
||||
pub struct Retry {
|
||||
pub error: Option<String>,
|
||||
pub failures: u32,
|
||||
pub retry_at: Option<i64>,
|
||||
pub halted: bool,
|
||||
}
|
||||
impl Retry {
|
||||
pub fn remaining(&self, now: i64) -> u64 {
|
||||
self.retry_at
|
||||
.map(|at| at.saturating_sub(now).clamp(0, 3600) as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
impl Workspace {
|
||||
pub fn sync_retry(&self, binding: &str) -> Result<Retry> {
|
||||
self.check_binding(binding)?;
|
||||
Ok(self
|
||||
.db
|
||||
.query_row(
|
||||
"SELECT error,failures,retry_at,halted FROM sync_retry WHERE binding=?1",
|
||||
[binding],
|
||||
|row| {
|
||||
Ok(Retry {
|
||||
error: row.get(0)?,
|
||||
failures: row.get(1)?,
|
||||
retry_at: row.get(2)?,
|
||||
halted: row.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()?
|
||||
.unwrap_or_default())
|
||||
}
|
||||
pub fn sync_retry_clear(&mut self, binding: &str) -> Result<()> {
|
||||
self.check_binding(binding)?;
|
||||
self.db
|
||||
.execute("DELETE FROM sync_retry WHERE binding=?1", [binding])?;
|
||||
Ok(())
|
||||
}
|
||||
pub fn sync_retry_fail(
|
||||
&mut self,
|
||||
binding: &str,
|
||||
code: &str,
|
||||
status: u16,
|
||||
retry_after: Option<u64>,
|
||||
now: i64,
|
||||
) -> Result<()> {
|
||||
let previous = self.sync_retry(binding)?;
|
||||
if code == "SYNC_CANCELLED" {
|
||||
return Ok(());
|
||||
}
|
||||
// Only retain a bounded machine code, never an arbitrary remote response string.
|
||||
let code = if !code.is_empty()
|
||||
&& code.len() <= 80
|
||||
&& code
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
|
||||
{
|
||||
code
|
||||
} else {
|
||||
"SYNC_REMOTE_ERROR"
|
||||
};
|
||||
let failures = if code == "CREDENTIALS_LOCKED" {
|
||||
0
|
||||
} else {
|
||||
previous.failures.saturating_add(1)
|
||||
};
|
||||
let halted = matches!(status, 401 | 403 | 413 | 426 | 507)
|
||||
|| matches!(code, "PROTOCOL_INCOMPATIBLE" | "SYNC_LOGIN_REQUIRED");
|
||||
let retry_at = now.saturating_add(
|
||||
retry_after
|
||||
.unwrap_or(2u64.saturating_pow(failures.min(8)))
|
||||
.clamp(1, 3600) as i64,
|
||||
);
|
||||
self.db.execute("INSERT INTO sync_retry VALUES (?1,?2,?3,?4,?5) ON CONFLICT(binding) DO UPDATE SET error=excluded.error,failures=excluded.failures,retry_at=excluded.retry_at,halted=excluded.halted", params![binding,code,failures,retry_at,halted])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn retry_history_survives_twenty_reopens_and_isolates_bindings() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut ws = Workspace::open(dir.path()).unwrap();
|
||||
let b = ws
|
||||
.sync_bind_empty("https://sync.example", "remote", "account")
|
||||
.unwrap();
|
||||
for attempt in 1..=20 {
|
||||
ws.sync_retry_fail(&b.id, "NETWORK_ERROR", 0, Some(120), 1000)
|
||||
.unwrap();
|
||||
drop(ws);
|
||||
ws = Workspace::open(dir.path()).unwrap();
|
||||
let state = ws.sync_retry(&b.id).unwrap();
|
||||
assert_eq!(state.failures, attempt);
|
||||
assert_eq!(state.remaining(1010), 110);
|
||||
assert_eq!(state.remaining(-10000), 3600);
|
||||
assert_eq!(state.remaining(1200), 0);
|
||||
}
|
||||
ws.sync_retry_fail(&b.id, "SYNC_CANCELLED", 0, None, 1100)
|
||||
.unwrap();
|
||||
assert_eq!(ws.sync_retry(&b.id).unwrap().failures, 20);
|
||||
ws.sync_retry_fail(&b.id, "UNAUTHORIZED", 401, None, 1100)
|
||||
.unwrap();
|
||||
drop(ws);
|
||||
ws = Workspace::open(dir.path()).unwrap();
|
||||
assert!(ws.sync_retry(&b.id).unwrap().halted);
|
||||
ws.sync_retry_clear(&b.id).unwrap();
|
||||
assert!(ws.sync_retry(&b.id).unwrap().error.is_none());
|
||||
ws.sync_retry_fail(&b.id, "secret response body", 500, None, 1100)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ws.sync_retry(&b.id).unwrap().error.as_deref(),
|
||||
Some("SYNC_REMOTE_ERROR")
|
||||
);
|
||||
ws.sync_unbind(&b.id).unwrap();
|
||||
let next = ws
|
||||
.sync_bind_empty("https://sync.example", "other", "account")
|
||||
.unwrap();
|
||||
assert!(ws.sync_retry(&next.id).unwrap().error.is_none());
|
||||
assert!(ws.sync_retry_clear(&b.id).is_err());
|
||||
}
|
||||
}
|
||||
@@ -135,10 +135,10 @@ impl Workspace {
|
||||
let db = Connection::open(db_path)?;
|
||||
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?;
|
||||
let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
||||
if version > 8 {
|
||||
if version > 9 {
|
||||
return Err(HostError::new("SCHEMA_INCOMPATIBLE"));
|
||||
}
|
||||
if (1..8).contains(&version) {
|
||||
if (1..9).contains(&version) {
|
||||
// Independent, complete SQLite backup before the schema ownership change.
|
||||
let backup = managed.join(format!("host-schema{version}-{}.sqlite3", Uuid::new_v4()));
|
||||
db.execute("VACUUM INTO ?1", [backup.to_string_lossy().as_ref()])?;
|
||||
@@ -162,6 +162,7 @@ impl Workspace {
|
||||
CREATE TABLE IF NOT EXISTS sync_initial_items (binding TEXT NOT NULL,sequence INTEGER NOT NULL,revision TEXT NOT NULL,PRIMARY KEY(binding,sequence));
|
||||
CREATE TABLE IF NOT EXISTS payloads (operation_id TEXT PRIMARY KEY,hash TEXT NOT NULL,size INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS sync_observed (file_id TEXT PRIMARY KEY,path TEXT NOT NULL,hash TEXT NOT NULL,deleted INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS sync_retry (binding TEXT PRIMARY KEY,error TEXT,failures INTEGER NOT NULL,retry_at INTEGER,halted INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS sync_preferences (binding TEXT PRIMARY KEY,paused INTEGER NOT NULL DEFAULT 0);
|
||||
CREATE TABLE IF NOT EXISTS sync_resolutions (binding TEXT NOT NULL,sequence INTEGER NOT NULL,choice TEXT NOT NULL,destination TEXT NOT NULL,expected TEXT NOT NULL,operation_id TEXT NOT NULL,rename_id TEXT NOT NULL,copy_id TEXT NOT NULL,state TEXT NOT NULL,PRIMARY KEY(binding,sequence));")?;
|
||||
let has_origin: bool = db.query_row(
|
||||
@@ -178,7 +179,7 @@ impl Workspace {
|
||||
if version < 7 {
|
||||
db.execute_batch("INSERT OR IGNORE INTO sync_observed SELECT f.id,COALESCE((SELECT o.path FROM outbox o WHERE o.file_id=f.id AND o.state IN ('pending','queued') ORDER BY rowid DESC LIMIT 1),(SELECT h.path FROM sync_heads h JOIN sync_bindings b ON h.binding=b.id WHERE h.file_id=f.id AND b.state='active'),f.path),COALESCE((SELECT o.hash FROM outbox o WHERE o.file_id=f.id AND o.state IN ('pending','queued') ORDER BY rowid DESC LIMIT 1),(SELECT h.hash FROM sync_heads h JOIN sync_bindings b ON h.binding=b.id WHERE h.file_id=f.id AND b.state='active'),f.hash),f.deleted FROM files f;")?;
|
||||
}
|
||||
db.execute_batch("PRAGMA user_version=8; COMMIT;")?;
|
||||
db.execute_batch("PRAGMA user_version=9; COMMIT;")?;
|
||||
let vault_id: String = db
|
||||
.query_row("SELECT id FROM identity", [], |r| r.get(0))
|
||||
.optional()?
|
||||
|
||||
@@ -62,3 +62,12 @@ it('requires a reviewed merge fingerprint and invalidates preview when the remot
|
||||
expect(hostInvoke).toHaveBeenCalledWith('sync_bind', { request: { vault_id: 'local', endpoint: 'https://test.example/', account: 'test', remote_vault: 'one', mode: 'merge', fingerprint: 'reviewed-snapshot' } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows a persisted halt without suggesting an automatic retry countdown', async () => {
|
||||
vi.mocked(hostInvoke).mockResolvedValue({ ...empty(), binding: { id: 'binding', endpoint: 'https://test.example/', account: 'test', remote_vault: 'remote', cursor: 7 }, halted: true, failures: 3, error: 'UNAUTHORIZED', retry_in: 120 })
|
||||
const wrapper = mount(SyncSettings); await flushPromises()
|
||||
expect(wrapper.text()).toContain('自动同步已停止')
|
||||
expect(wrapper.text()).toContain('UNAUTHORIZED')
|
||||
expect(wrapper.text()).not.toContain('120s')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import { t } from '@/i18n'
|
||||
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
|
||||
interface Binding { id: string; endpoint: string; account: string; remote_vault: string; cursor: number }
|
||||
interface Conflict { sequence: number; local_path: string; local_hash: string; current_hash?: string; current_path?: string; remote: { path: string; operation: string } }
|
||||
interface Status { vault_id: string; binding: Binding | null; paused: boolean; pending: number; conflicts: Conflict[]; credential_state: string; running: boolean; error: string | null; retry_in: number | null }
|
||||
interface Status { vault_id: string; binding: Binding | null; paused: boolean; pending: number; conflicts: Conflict[]; credential_state: string; running: boolean; error: string | null; retry_in: number | null; failures: number; halted: boolean }
|
||||
interface RemoteVault { id: string; name: string; sequence: number; used: number; quota: number }
|
||||
const status = ref<Status | null>(null)
|
||||
const endpoint = ref('https://'), account = ref(''), password = ref(''), device = ref('OpenNexus Desktop'), testHttp = ref(false)
|
||||
@@ -120,9 +120,9 @@ onUnmounted(() => { mounted = false; clearInterval(timer); password.value = '' }
|
||||
</div>
|
||||
<div v-if="status?.binding" class="sync-bound">
|
||||
<p>{{ status.binding.endpoint }} · {{ status.binding.account }} · {{ status.binding.remote_vault }}</p>
|
||||
<p aria-live="polite">{{ status.paused ? t('已暂停', 'Paused') : status.running ? t('同步中', 'Syncing') : t('等待下一轮同步', 'Waiting for next sync') }} · {{ t('待上传', 'Pending') }} {{ status.pending }} · cursor {{ status.binding.cursor }}</p>
|
||||
<p aria-live="polite">{{ status.paused ? t('已暂停', 'Paused') : status.running ? t('同步中', 'Syncing') : status.halted ? t('自动同步已停止,请处理错误后重试', 'Automatic sync stopped; resolve the error and retry') : t('等待下一轮同步', 'Waiting for next sync') }} · {{ t('待上传', 'Pending') }} {{ status.pending }} · cursor {{ status.binding.cursor }}</p>
|
||||
<p v-if="status.credential_state !== 'ready'" role="status">{{ status.credential_state }}</p>
|
||||
<p v-if="status.error" role="alert">{{ status.error }}<span v-if="status.retry_in"> · {{ status.retry_in }}s</span></p>
|
||||
<p v-if="status.error" role="alert">{{ status.error }}<span v-if="status.retry_in && !status.halted"> · {{ status.retry_in }}s</span></p>
|
||||
<div class="inline-actions">
|
||||
<button :disabled="busy || status.running || status.paused" @click="act(async () => { await hostInvoke('sync_run') })">{{ t('立即同步', 'Sync now') }}</button>
|
||||
<button :disabled="busy" @click="act(async () => { await hostInvoke('sync_pause', { bindingId: status!.binding!.id, paused: !status!.paused }) })">{{ status.paused ? t('继续同步', 'Resume sync') : t('暂停同步', 'Pause sync') }}</button>
|
||||
|
||||
Reference in New Issue
Block a user