Address PR review for WebDAV and armv7

This commit is contained in:
ciat-777
2026-03-21 00:48:42 +08:00
parent bc52b6b5fd
commit 1ce036fd46
8 changed files with 396 additions and 52 deletions
+29
View File
@@ -299,6 +299,35 @@ jobs:
- name: Audit production dependencies - name: Audit production dependencies
run: npm audit --omit=dev --audit-level=high run: npm audit --omit=dev --audit-level=high
docker-smoke-armv7:
name: Docker Smoke Build (armv7)
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
platforms: arm
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build linux/arm/v7 image
uses: docker/build-push-action@v7
with:
context: .
file: docker/Dockerfile
platforms: linux/arm/v7
push: false
provenance: false
cache-from: type=gha,scope=ci-armv7-pr
cache-to: type=gha,mode=max,scope=ci-armv7-pr
publish-docker-arch: publish-docker-arch:
name: Publish Docker Image (${{ matrix.arch }}) name: Publish Docker Image (${{ matrix.arch }})
if: github.event_name == 'push' if: github.event_name == 'push'
+1
View File
@@ -347,6 +347,7 @@ docker run -d --name metapi \
> Docker 镜像支持 `amd64`、`arm64` 和 `armv7l`(`linux/arm/v7`)服务端部署。 > Docker 镜像支持 `amd64`、`arm64` 和 `armv7l`(`linux/arm/v7`)服务端部署。
> 当前 `armv7l` 支持范围仅限服务端 / Docker 运行,不包含桌面安装包。 > 当前 `armv7l` 支持范围仅限服务端 / Docker 运行,不包含桌面安装包。
<!-- markdownlint-disable-next-line MD028 -->
> [!IMPORTANT] > [!IMPORTANT]
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。数据存储在 `./data` 目录,升级不会丢失。 > 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。数据存储在 `./data` 目录,升级不会丢失。
+1
View File
@@ -348,6 +348,7 @@ After starting, visit `http://localhost:4000` and log in with your `AUTH_TOKEN`!
> Docker images support `amd64`, `arm64`, and `armv7l` (`linux/arm/v7`) server deployments. > Docker images support `amd64`, `arm64`, and `armv7l` (`linux/arm/v7`) server deployments.
> Current `armv7l` support is limited to server / Docker usage and does not include Electron desktop packaging support. > Current `armv7l` support is limited to server / Docker usage and does not include Electron desktop packaging support.
<!-- markdownlint-disable-next-line MD028 -->
> [!IMPORTANT] > [!IMPORTANT]
> Make sure to change `AUTH_TOKEN` and `PROXY_TOKEN` — do not use default values. Data is stored in the `./data` directory and persists across upgrades. > Make sure to change `AUTH_TOKEN` and `PROXY_TOKEN` — do not use default values. Data is stored in the `./data` directory and persists across upgrades.
+9
View File
@@ -15,4 +15,13 @@ describe('docker workflows', () => {
expect(releaseWorkflow).toContain('platform: linux/arm/v7'); expect(releaseWorkflow).toContain('platform: linux/arm/v7');
expect(releaseWorkflow).toContain('"${tag}-armv7"'); expect(releaseWorkflow).toContain('"${tag}-armv7"');
}); });
it('smoke builds the armv7 docker image on pull requests', () => {
const ciWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/ci.yml'), 'utf8');
expect(ciWorkflow).toContain('Docker Smoke Build (armv7)');
expect(ciWorkflow).toContain('if: github.event_name == \'pull_request\'');
expect(ciWorkflow).toContain('platforms: linux/arm/v7');
expect(ciWorkflow).toContain('push: false');
});
}); });
+105
View File
@@ -741,4 +741,109 @@ describe('backupService', () => {
fetchSpy.mockRestore(); fetchSpy.mockRestore();
}); });
it('times out stalled webdav export requests', async () => {
vi.useFakeTimers();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockImplementation((_, init) => {
const signal = init?.signal as AbortSignal | undefined;
if (!signal) {
return Promise.reject(new Error('missing abort signal')) as Promise<Response>;
}
return new Promise<Response>((_, reject) => {
signal.addEventListener('abort', () => {
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
}, { once: true });
});
});
try {
await db.insert(schema.settings).values({
key: 'backup_webdav_config_v1',
value: JSON.stringify({
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi.json',
username: 'alice',
password: 'secret-pass',
exportType: 'all',
autoSyncEnabled: false,
autoSyncCron: '0 */6 * * *',
}),
}).run();
const exportAssertion = expect(backupService.exportBackupToWebdav()).rejects.toThrow('WebDAV 请求超时');
await vi.advanceTimersByTimeAsync(16_000);
await exportAssertion;
} finally {
fetchSpy.mockRestore();
vi.useRealTimers();
}
});
it('times out stalled webdav import requests', async () => {
vi.useFakeTimers();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockImplementation((_, init) => {
const signal = init?.signal as AbortSignal | undefined;
if (!signal) {
return Promise.reject(new Error('missing abort signal')) as Promise<Response>;
}
return new Promise<Response>((_, reject) => {
signal.addEventListener('abort', () => {
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
}, { once: true });
});
});
try {
await db.insert(schema.settings).values({
key: 'backup_webdav_config_v1',
value: JSON.stringify({
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi.json',
username: 'alice',
password: 'secret-pass',
exportType: 'all',
autoSyncEnabled: false,
autoSyncCron: '0 */6 * * *',
}),
}).run();
const importAssertion = expect(backupService.importBackupFromWebdav()).rejects.toThrow('WebDAV 请求超时');
await vi.advanceTimersByTimeAsync(16_000);
await importAssertion;
} finally {
fetchSpy.mockRestore();
vi.useRealTimers();
}
});
it('does not schedule malformed imported webdav config', async () => {
const cronModule = await import('node-cron');
const scheduleSpy = vi.spyOn(cronModule.default, 'schedule');
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
try {
await db.insert(schema.settings).values({
key: 'backup_webdav_config_v1',
value: JSON.stringify({
enabled: true,
fileUrl: 'not-a-valid-url',
username: 'alice',
password: 'secret-pass',
exportType: 'all',
autoSyncEnabled: true,
autoSyncCron: '0 */6 * * *',
}),
}).run();
await expect(backupService.reloadBackupWebdavScheduler()).resolves.toBeUndefined();
expect(scheduleSpy).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('invalid config'));
} finally {
backupService.__resetBackupWebdavSchedulerForTests();
scheduleSpy.mockRestore();
warnSpy.mockRestore();
}
});
}); });
+33 -5
View File
@@ -105,6 +105,7 @@ const EXCLUDED_SETTING_KEYS = new Set<string>([
const BACKUP_WEBDAV_CONFIG_SETTING_KEY = 'backup_webdav_config_v1'; const BACKUP_WEBDAV_CONFIG_SETTING_KEY = 'backup_webdav_config_v1';
const BACKUP_WEBDAV_STATE_SETTING_KEY = 'backup_webdav_state_v1'; const BACKUP_WEBDAV_STATE_SETTING_KEY = 'backup_webdav_state_v1';
const BACKUP_WEBDAV_DEFAULT_AUTO_SYNC_CRON = '0 */6 * * *'; const BACKUP_WEBDAV_DEFAULT_AUTO_SYNC_CRON = '0 */6 * * *';
const BACKUP_WEBDAV_FETCH_TIMEOUT_MS = 15_000;
let backupWebdavTask: cron.ScheduledTask | null = null; let backupWebdavTask: cron.ScheduledTask | null = null;
const DIRECT_API_PLATFORMS = new Set([ const DIRECT_API_PLATFORMS = new Set([
@@ -848,6 +849,30 @@ function validateBackupWebdavConfig(config: BackupWebdavConfig) {
} }
} }
async function fetchBackupWebdav(url: string, init: RequestInit): Promise<Response> {
const controller = new AbortController();
let timeoutHandle: ReturnType<typeof setTimeout> | null = setTimeout(() => {
controller.abort();
}, BACKUP_WEBDAV_FETCH_TIMEOUT_MS);
try {
return await fetch(url, {
...init,
signal: controller.signal,
});
} catch (error: any) {
if (error?.name === 'AbortError') {
throw new Error(`WebDAV 请求超时(${Math.max(1, Math.round(BACKUP_WEBDAV_FETCH_TIMEOUT_MS / 1000))}s)`);
}
throw error;
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
}
}
function stopBackupWebdavScheduler() { function stopBackupWebdavScheduler() {
backupWebdavTask?.stop(); backupWebdavTask?.stop();
backupWebdavTask = null; backupWebdavTask = null;
@@ -1294,7 +1319,7 @@ export async function exportBackupToWebdav(type?: BackupExportType) {
if (authHeader) headers.Authorization = authHeader; if (authHeader) headers.Authorization = authHeader;
try { try {
const response = await fetch(config.fileUrl, { const response = await fetchBackupWebdav(config.fileUrl, {
method: 'PUT', method: 'PUT',
headers, headers,
body: JSON.stringify(payload, null, 2), body: JSON.stringify(payload, null, 2),
@@ -1342,7 +1367,7 @@ export async function importBackupFromWebdav() {
if (authHeader) headers.Authorization = authHeader; if (authHeader) headers.Authorization = authHeader;
try { try {
const response = await fetch(config.fileUrl, { const response = await fetchBackupWebdav(config.fileUrl, {
method: 'GET', method: 'GET',
headers, headers,
}); });
@@ -1381,9 +1406,12 @@ export async function importBackupFromWebdav() {
export async function reloadBackupWebdavScheduler() { export async function reloadBackupWebdavScheduler() {
stopBackupWebdavScheduler(); stopBackupWebdavScheduler();
const config = await loadBackupWebdavConfig(); const config = await loadBackupWebdavConfig();
if (!config.enabled || !config.autoSyncEnabled || !config.fileUrl) return; if (!config.enabled || !config.autoSyncEnabled) return;
if (!cron.validate(config.autoSyncCron)) {
console.warn(`[backup/webdav] invalid auto sync cron: ${config.autoSyncCron}`); try {
validateBackupWebdavConfig(config);
} catch (error: any) {
console.warn(`[backup/webdav] invalid config: ${error?.message || 'unknown error'}`);
return; return;
} }
+106
View File
@@ -393,4 +393,110 @@ describe('ImportExport', () => {
root?.unmount(); root?.unmount();
} }
}); });
it('disables webdav actions while config has unsaved changes', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ImportExport />
</MemoryRouter>,
);
});
await flushMicrotasks();
const fileUrlInput = root!.root.findAll((node) => (
node.type === 'input'
&& node.props.placeholder === 'https://dav.example.com/backups/metapi.json'
)).at(-1);
expect(fileUrlInput).toBeTruthy();
await act(async () => {
fileUrlInput!.props.onChange({ target: { value: 'https://dav.example.com/backups/changed.json' } });
});
await flushMicrotasks();
const exportButton = root!.root.findAll((node) => (
node.type === 'button'
&& collectText(node).includes('立即导出到 WebDAV')
)).at(-1);
const importButton = root!.root.findAll((node) => (
node.type === 'button'
&& collectText(node).includes('从 WebDAV 拉取')
)).at(-1);
expect(exportButton?.props.disabled).toBe(true);
expect(importButton?.props.disabled).toBe(true);
} finally {
root?.unmount();
}
});
it('can clear a saved webdav password', async () => {
apiMock.saveBackupWebdavConfig.mockResolvedValueOnce({
success: true,
config: {
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi.json',
username: 'alice',
exportType: 'all',
autoSyncEnabled: true,
autoSyncCron: '0 */6 * * *',
hasPassword: false,
passwordMasked: '',
},
state: {
lastSyncAt: null,
lastError: null,
},
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ImportExport />
</MemoryRouter>,
);
});
await flushMicrotasks();
const clearPasswordToggle = root!.root.findAll((node) => (
node.type === 'label'
&& collectText(node).includes('清空已保存密码')
)).at(-1);
expect(clearPasswordToggle).toBeTruthy();
const checkbox = clearPasswordToggle!.findByType('input');
await act(async () => {
checkbox.props.onChange({ target: { checked: true } });
});
await flushMicrotasks();
const saveButton = root!.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('保存 WebDAV 配置')
)).at(-1);
expect(saveButton).toBeTruthy();
await act(async () => {
saveButton!.props.onClick();
});
await flushMicrotasks();
expect(apiMock.saveBackupWebdavConfig).toHaveBeenCalledWith(expect.objectContaining({
clearPassword: true,
}));
} finally {
root?.unmount();
}
});
}); });
+112 -47
View File
@@ -41,6 +41,16 @@ type WebdavSyncState = {
lastError: string | null; lastError: string | null;
}; };
type WebdavConfigSnapshot = {
enabled: boolean;
fileUrl: string;
username: string;
exportType: BackupType;
autoSyncEnabled: boolean;
autoSyncCron: string;
hasPassword: boolean;
};
const DEFAULT_WEBDAV_CONFIG: WebdavConfigForm = { const DEFAULT_WEBDAV_CONFIG: WebdavConfigForm = {
enabled: false, enabled: false,
fileUrl: '', fileUrl: '',
@@ -53,6 +63,16 @@ const DEFAULT_WEBDAV_CONFIG: WebdavConfigForm = {
passwordMasked: '', passwordMasked: '',
}; };
const DEFAULT_WEBDAV_SNAPSHOT: WebdavConfigSnapshot = {
enabled: false,
fileUrl: '',
username: '',
exportType: 'all',
autoSyncEnabled: false,
autoSyncCron: '0 */6 * * *',
hasPassword: false,
};
function downloadJsonFile(data: unknown, filename: string) { function downloadJsonFile(data: unknown, filename: string) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -218,35 +238,71 @@ export default function ImportExport() {
const [selectedFileName, setSelectedFileName] = useState(''); const [selectedFileName, setSelectedFileName] = useState('');
const [dragOver, setDragOver] = useState(false); const [dragOver, setDragOver] = useState(false);
const [webdavConfig, setWebdavConfig] = useState<WebdavConfigForm>(DEFAULT_WEBDAV_CONFIG); const [webdavConfig, setWebdavConfig] = useState<WebdavConfigForm>(DEFAULT_WEBDAV_CONFIG);
const [savedWebdavConfig, setSavedWebdavConfig] = useState<WebdavConfigSnapshot>(DEFAULT_WEBDAV_SNAPSHOT);
const [webdavState, setWebdavState] = useState<WebdavSyncState>({ lastSyncAt: null, lastError: null }); const [webdavState, setWebdavState] = useState<WebdavSyncState>({ lastSyncAt: null, lastError: null });
const [webdavSaving, setWebdavSaving] = useState(false); const [webdavSaving, setWebdavSaving] = useState(false);
const [webdavAction, setWebdavAction] = useState<'export' | 'import' | ''>(''); const [webdavAction, setWebdavAction] = useState<'export' | 'import' | ''>('');
const [clearWebdavPassword, setClearWebdavPassword] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const summary = useMemo(() => parseImportSummary(importData), [importData]); const summary = useMemo(() => parseImportSummary(importData), [importData]);
const buildWebdavForm = (config: any): WebdavConfigForm => ({
enabled: config.enabled === true,
fileUrl: String(config.fileUrl || ''),
username: String(config.username || ''),
password: '',
exportType: config.exportType === 'accounts' || config.exportType === 'preferences' ? config.exportType : 'all',
autoSyncEnabled: config.autoSyncEnabled === true,
autoSyncCron: String(config.autoSyncCron || DEFAULT_WEBDAV_CONFIG.autoSyncCron),
hasPassword: config.hasPassword === true,
passwordMasked: String(config.passwordMasked || ''),
});
const buildWebdavSnapshot = (config: any): WebdavConfigSnapshot => ({
enabled: config.enabled === true,
fileUrl: String(config.fileUrl || ''),
username: String(config.username || ''),
exportType: config.exportType === 'accounts' || config.exportType === 'preferences' ? config.exportType : 'all',
autoSyncEnabled: config.autoSyncEnabled === true,
autoSyncCron: String(config.autoSyncCron || DEFAULT_WEBDAV_CONFIG.autoSyncCron),
hasPassword: config.hasPassword === true,
});
const applyWebdavResponse = (result: any) => {
const config = result?.config;
if (config) {
setWebdavConfig(buildWebdavForm(config));
setSavedWebdavConfig(buildWebdavSnapshot(config));
setClearWebdavPassword(false);
}
const state = result?.state || result;
setWebdavState((prev) => ({
lastSyncAt: typeof state?.lastSyncAt === 'string' ? state.lastSyncAt : prev.lastSyncAt,
lastError: typeof state?.lastError === 'string'
? state.lastError
: (state?.lastError === null ? null : prev.lastError),
}));
};
const webdavConfigDirty = (
webdavConfig.enabled !== savedWebdavConfig.enabled
|| webdavConfig.fileUrl !== savedWebdavConfig.fileUrl
|| webdavConfig.username !== savedWebdavConfig.username
|| webdavConfig.exportType !== savedWebdavConfig.exportType
|| webdavConfig.autoSyncEnabled !== savedWebdavConfig.autoSyncEnabled
|| webdavConfig.autoSyncCron !== savedWebdavConfig.autoSyncCron
|| webdavConfig.hasPassword !== savedWebdavConfig.hasPassword
|| webdavConfig.password.trim().length > 0
|| clearWebdavPassword
);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
void api.getBackupWebdavConfig() void api.getBackupWebdavConfig()
.then((result: any) => { .then((result: any) => {
if (!alive) return; if (!alive) return;
const config = result?.config || {}; applyWebdavResponse(result);
const state = result?.state || {};
setWebdavConfig({
enabled: config.enabled === true,
fileUrl: String(config.fileUrl || ''),
username: String(config.username || ''),
password: '',
exportType: config.exportType === 'accounts' || config.exportType === 'preferences' ? config.exportType : 'all',
autoSyncEnabled: config.autoSyncEnabled === true,
autoSyncCron: String(config.autoSyncCron || DEFAULT_WEBDAV_CONFIG.autoSyncCron),
hasPassword: config.hasPassword === true,
passwordMasked: String(config.passwordMasked || ''),
});
setWebdavState({
lastSyncAt: typeof state.lastSyncAt === 'string' ? state.lastSyncAt : null,
lastError: typeof state.lastError === 'string' ? state.lastError : null,
});
}) })
.catch((err: any) => { .catch((err: any) => {
if (!alive) return; if (!alive) return;
@@ -346,33 +402,10 @@ export default function ImportExport() {
} }
}; };
const applyWebdavResponse = (result: any) => {
const config = result?.config;
if (config) {
setWebdavConfig((prev) => ({
enabled: config.enabled === true,
fileUrl: String(config.fileUrl || ''),
username: String(config.username || ''),
password: '',
exportType: config.exportType === 'accounts' || config.exportType === 'preferences' ? config.exportType : 'all',
autoSyncEnabled: config.autoSyncEnabled === true,
autoSyncCron: String(config.autoSyncCron || prev.autoSyncCron || DEFAULT_WEBDAV_CONFIG.autoSyncCron),
hasPassword: config.hasPassword === true,
passwordMasked: String(config.passwordMasked || ''),
}));
}
const state = result?.state || result;
setWebdavState((prev) => ({
lastSyncAt: typeof state?.lastSyncAt === 'string' ? state.lastSyncAt : prev.lastSyncAt,
lastError: typeof state?.lastError === 'string'
? state.lastError
: (state?.lastError === null ? null : prev.lastError),
}));
};
const handleSaveWebdavConfig = async () => { const handleSaveWebdavConfig = async () => {
setWebdavSaving(true); setWebdavSaving(true);
try { try {
const nextPassword = webdavConfig.password.trim();
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
enabled: webdavConfig.enabled, enabled: webdavConfig.enabled,
fileUrl: webdavConfig.fileUrl, fileUrl: webdavConfig.fileUrl,
@@ -381,12 +414,13 @@ export default function ImportExport() {
autoSyncEnabled: webdavConfig.autoSyncEnabled, autoSyncEnabled: webdavConfig.autoSyncEnabled,
autoSyncCron: webdavConfig.autoSyncCron, autoSyncCron: webdavConfig.autoSyncCron,
}; };
if (webdavConfig.password.trim()) { if (nextPassword) {
payload.password = webdavConfig.password; payload.password = webdavConfig.password;
} else if (clearWebdavPassword) {
payload.clearPassword = true;
} }
const result = await api.saveBackupWebdavConfig(payload as any); const result = await api.saveBackupWebdavConfig(payload as any);
applyWebdavResponse(result); applyWebdavResponse(result);
setWebdavConfig((prev) => ({ ...prev, password: '' }));
toast.success('WebDAV 配置已保存'); toast.success('WebDAV 配置已保存');
} catch (err: any) { } catch (err: any) {
toast.error(err?.message || '保存 WebDAV 配置失败'); toast.error(err?.message || '保存 WebDAV 配置失败');
@@ -662,10 +696,35 @@ export default function ImportExport() {
<input <input
type="password" type="password"
value={webdavConfig.password} value={webdavConfig.password}
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, password: e.target.value }))} onChange={(e) => {
placeholder={webdavConfig.hasPassword ? `已保存 ${webdavConfig.passwordMasked},留空则保持不变` : '请输入密码'} const nextPassword = e.target.value;
setWebdavConfig((prev) => ({ ...prev, password: nextPassword }));
if (nextPassword.trim()) {
setClearWebdavPassword(false);
}
}}
placeholder={clearWebdavPassword
? '保存后将清空已存密码'
: (webdavConfig.hasPassword ? `已保存 ${webdavConfig.passwordMasked},留空则保持不变` : '请输入密码')}
disabled={clearWebdavPassword}
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
{webdavConfig.hasPassword ? (
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--color-text-secondary)' }}>
<input
type="checkbox"
checked={clearWebdavPassword}
onChange={(e) => {
const checked = e.target.checked === true;
setClearWebdavPassword(checked);
if (checked) {
setWebdavConfig((prev) => ({ ...prev, password: '' }));
}
}}
/>
清空已保存密码
</label>
) : null}
</label> </label>
<label style={{ display: 'grid', gap: 6 }}> <label style={{ display: 'grid', gap: 6 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>导出分区</span> <span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>导出分区</span>
@@ -717,7 +776,7 @@ export default function ImportExport() {
</button> </button>
<button <button
onClick={handleExportToWebdav} onClick={handleExportToWebdav}
disabled={webdavAction !== ''} disabled={webdavAction !== '' || webdavSaving || webdavConfigDirty}
className="btn btn-ghost" className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }} style={{ border: '1px solid var(--color-border)' }}
> >
@@ -725,7 +784,7 @@ export default function ImportExport() {
</button> </button>
<button <button
onClick={handleImportFromWebdav} onClick={handleImportFromWebdav}
disabled={webdavAction !== ''} disabled={webdavAction !== '' || webdavSaving || webdavConfigDirty}
className="btn btn-ghost" className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }} style={{ border: '1px solid var(--color-border)' }}
> >
@@ -733,6 +792,12 @@ export default function ImportExport() {
</button> </button>
</div> </div>
{webdavConfigDirty ? (
<div style={{ marginTop: 10, fontSize: 12, color: 'var(--color-warning)' }}>
当前 WebDAV 配置有未保存改动,请先保存后再执行导入或导出。
</div>
) : null}
<div style={{ marginTop: 12, fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.7 }}> <div style={{ marginTop: 12, fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>
<div>上次同步:{webdavState.lastSyncAt ? new Date(webdavState.lastSyncAt).toLocaleString() : '尚未同步'}</div> <div>上次同步:{webdavState.lastSyncAt ? new Date(webdavState.lastSyncAt).toLocaleString() : '尚未同步'}</div>
<div>最近错误:{webdavState.lastError || '无'}</div> <div>最近错误:{webdavState.lastError || '无'}</div>