1
0
forked from boos/BoosAPI

fix(web): polish webdav backup form controls

This commit is contained in:
ciat-777
2026-03-21 14:05:52 +08:00
parent e454ec18ec
commit dbc17f6907
2 changed files with 140 additions and 45 deletions
+49
View File
@@ -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;
+78 -32
View File
@@ -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,50 +756,61 @@ 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>
<div style={{ display: 'grid', gap: 8, alignContent: 'center' }}> </div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', marginTop: 16 }}>
<label
style={{
...webdavToggleStyle,
color: webdavConfig.enabled ? 'var(--color-primary)' : 'var(--color-text-secondary)',
}}
>
<input <input
type="checkbox" type="checkbox"
checked={webdavConfig.enabled} checked={webdavConfig.enabled}
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, enabled: e.target.checked }))} onChange={(e) => setWebdavConfig((prev) => ({ ...prev, enabled: e.target.checked }))}
style={{ width: 16, height: 16, cursor: 'pointer', accentColor: 'var(--color-primary)' }}
/> />
启用 WebDAV 启用 WebDAV
</label> </label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}> <label
style={{
...webdavToggleStyle,
color: webdavConfig.autoSyncEnabled ? 'var(--color-primary)' : 'var(--color-text-secondary)',
}}
>
<input <input
type="checkbox" type="checkbox"
checked={webdavConfig.autoSyncEnabled} checked={webdavConfig.autoSyncEnabled}
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, autoSyncEnabled: e.target.checked }))} onChange={(e) => setWebdavConfig((prev) => ({ ...prev, autoSyncEnabled: e.target.checked }))}
style={{ width: 16, height: 16, cursor: 'pointer', accentColor: 'var(--color-primary)' }}
/> />
自动同步 自动同步
</label> </label>
</div> </div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14 }}> <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
<button <button