forked from boos/BoosAPI
fix(web): polish webdav backup form controls
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import ModernSelect from '../components/ModernSelect.js';
|
||||||
import ImportExport from './ImportExport.js';
|
import ImportExport from './ImportExport.js';
|
||||||
|
|
||||||
const { apiMock, toastMock } = vi.hoisted(() => ({
|
const { apiMock, toastMock } = vi.hoisted(() => ({
|
||||||
@@ -394,6 +395,54 @@ describe('ImportExport', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders webdav export type with ModernSelect instead of a native select', 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);
|
||||||
|
const cronInput = root!.root.findAll((node) => (
|
||||||
|
node.type === 'input'
|
||||||
|
&& node.props.placeholder === '0 */6 * * *'
|
||||||
|
)).at(-1);
|
||||||
|
const selects = root!.root.findAllByType(ModernSelect);
|
||||||
|
const exportTypeSelect = selects.at(-1);
|
||||||
|
|
||||||
|
expect(fileUrlInput?.props.style).toEqual(expect.objectContaining({
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 14px',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: 13,
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
}));
|
||||||
|
expect(root!.root.findAll((node) => node.type === 'select')).toHaveLength(0);
|
||||||
|
expect(exportTypeSelect?.props.value).toBe('all');
|
||||||
|
expect(exportTypeSelect?.props.options).toEqual([
|
||||||
|
{ value: 'all', label: '全部' },
|
||||||
|
{ value: 'accounts', label: '账号与路由' },
|
||||||
|
{ value: 'preferences', label: '系统设置' },
|
||||||
|
]);
|
||||||
|
expect(cronInput?.props.style).toEqual(expect.objectContaining({
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('disables webdav actions while config has unsaved changes', async () => {
|
it('disables webdav actions while config has unsaved changes', async () => {
|
||||||
let root: ReturnType<typeof create> | null = null;
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
|
import ModernSelect from '../components/ModernSelect.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import { tr } from '../i18n.js';
|
import { tr } from '../i18n.js';
|
||||||
|
|
||||||
@@ -73,6 +74,40 @@ const DEFAULT_WEBDAV_SNAPSHOT: WebdavConfigSnapshot = {
|
|||||||
hasPassword: false,
|
hasPassword: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const WEBDAV_EXPORT_TYPE_OPTIONS = [
|
||||||
|
{ value: 'all', label: '全部' },
|
||||||
|
{ value: 'accounts', label: '账号与路由' },
|
||||||
|
{ value: 'preferences', label: '系统设置' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const formFieldLabelStyle: React.CSSProperties = {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: 8,
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingsInputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 14px',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: 13,
|
||||||
|
outline: 'none',
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
transition: 'border-color 0.2s',
|
||||||
|
};
|
||||||
|
|
||||||
|
const webdavToggleStyle: React.CSSProperties = {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
|
|
||||||
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);
|
||||||
@@ -672,27 +707,27 @@ export default function ImportExport() {
|
|||||||
支持手动推送、手动拉取,以及定时自动导出到 WebDAV。
|
支持手动推送、手动拉取,以及定时自动导出到 WebDAV。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(220px,1fr))', gap: 10 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '16px 20px' }}>
|
||||||
<label style={{ display: 'grid', gap: 6 }}>
|
<div style={{ gridColumn: '1 / -1' }}>
|
||||||
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>文件 URL</span>
|
<div style={formFieldLabelStyle}>文件 URL</div>
|
||||||
<input
|
<input
|
||||||
value={webdavConfig.fileUrl}
|
value={webdavConfig.fileUrl}
|
||||||
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, fileUrl: e.target.value }))}
|
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, fileUrl: e.target.value }))}
|
||||||
placeholder="https://dav.example.com/backups/metapi.json"
|
placeholder="https://dav.example.com/backups/metapi.json"
|
||||||
style={{ width: '100%' }}
|
style={settingsInputStyle}
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
<label style={{ display: 'grid', gap: 6 }}>
|
<div>
|
||||||
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>用户名</span>
|
<div style={formFieldLabelStyle}>用户名</div>
|
||||||
<input
|
<input
|
||||||
value={webdavConfig.username}
|
value={webdavConfig.username}
|
||||||
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, username: e.target.value }))}
|
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, username: e.target.value }))}
|
||||||
placeholder="可留空"
|
placeholder="可留空"
|
||||||
style={{ width: '100%' }}
|
style={settingsInputStyle}
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
<label style={{ display: 'grid', gap: 6 }}>
|
<div>
|
||||||
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>密码</span>
|
<div style={formFieldLabelStyle}>密码</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={webdavConfig.password}
|
value={webdavConfig.password}
|
||||||
@@ -707,10 +742,10 @@ export default function ImportExport() {
|
|||||||
? '保存后将清空已存密码'
|
? '保存后将清空已存密码'
|
||||||
: (webdavConfig.hasPassword ? `已保存 ${webdavConfig.passwordMasked},留空则保持不变` : '请输入密码')}
|
: (webdavConfig.hasPassword ? `已保存 ${webdavConfig.passwordMasked},留空则保持不变` : '请输入密码')}
|
||||||
disabled={clearWebdavPassword}
|
disabled={clearWebdavPassword}
|
||||||
style={{ width: '100%' }}
|
style={settingsInputStyle}
|
||||||
/>
|
/>
|
||||||
{webdavConfig.hasPassword ? (
|
{webdavConfig.hasPassword ? (
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
<label style={{ ...webdavToggleStyle, marginTop: 8, fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={clearWebdavPassword}
|
checked={clearWebdavPassword}
|
||||||
@@ -721,51 +756,62 @@ export default function ImportExport() {
|
|||||||
setWebdavConfig((prev) => ({ ...prev, password: '' }));
|
setWebdavConfig((prev) => ({ ...prev, password: '' }));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
style={{ width: 16, height: 16, cursor: 'pointer', accentColor: 'var(--color-primary)' }}
|
||||||
/>
|
/>
|
||||||
清空已保存密码
|
清空已保存密码
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
</label>
|
</div>
|
||||||
<label style={{ display: 'grid', gap: 6 }}>
|
<div>
|
||||||
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>导出分区</span>
|
<div style={formFieldLabelStyle}>导出分区</div>
|
||||||
<select
|
<ModernSelect
|
||||||
value={webdavConfig.exportType}
|
value={webdavConfig.exportType}
|
||||||
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, exportType: e.target.value as BackupType }))}
|
onChange={(value) => setWebdavConfig((prev) => ({ ...prev, exportType: value as BackupType }))}
|
||||||
>
|
options={[...WEBDAV_EXPORT_TYPE_OPTIONS]}
|
||||||
<option value="all">全部</option>
|
/>
|
||||||
<option value="accounts">账号与路由</option>
|
</div>
|
||||||
<option value="preferences">系统设置</option>
|
<div>
|
||||||
</select>
|
<div style={formFieldLabelStyle}>自动同步 Cron</div>
|
||||||
</label>
|
|
||||||
<label style={{ display: 'grid', gap: 6 }}>
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>自动同步 Cron</span>
|
|
||||||
<input
|
<input
|
||||||
value={webdavConfig.autoSyncCron}
|
value={webdavConfig.autoSyncCron}
|
||||||
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, autoSyncCron: e.target.value }))}
|
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, autoSyncCron: e.target.value }))}
|
||||||
placeholder="0 */6 * * *"
|
placeholder="0 */6 * * *"
|
||||||
style={{ width: '100%' }}
|
style={{ ...settingsInputStyle, fontFamily: 'var(--font-mono)' }}
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
<div style={{ display: 'grid', gap: 8, alignContent: 'center' }}>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={webdavConfig.enabled}
|
|
||||||
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, enabled: e.target.checked }))}
|
|
||||||
/>
|
|
||||||
启用 WebDAV
|
|
||||||
</label>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={webdavConfig.autoSyncEnabled}
|
|
||||||
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, autoSyncEnabled: e.target.checked }))}
|
|
||||||
/>
|
|
||||||
自动同步
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', marginTop: 16 }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
...webdavToggleStyle,
|
||||||
|
color: webdavConfig.enabled ? 'var(--color-primary)' : 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={webdavConfig.enabled}
|
||||||
|
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, enabled: e.target.checked }))}
|
||||||
|
style={{ width: 16, height: 16, cursor: 'pointer', accentColor: 'var(--color-primary)' }}
|
||||||
|
/>
|
||||||
|
启用 WebDAV
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
...webdavToggleStyle,
|
||||||
|
color: webdavConfig.autoSyncEnabled ? 'var(--color-primary)' : 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={webdavConfig.autoSyncEnabled}
|
||||||
|
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, autoSyncEnabled: e.target.checked }))}
|
||||||
|
style={{ width: 16, height: 16, cursor: 'pointer', accentColor: 'var(--color-primary)' }}
|
||||||
|
/>
|
||||||
|
自动同步
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
|
||||||
<button
|
<button
|
||||||
onClick={handleSaveWebdavConfig}
|
onClick={handleSaveWebdavConfig}
|
||||||
|
|||||||
Reference in New Issue
Block a user