This commit is contained in:
1 parent
3fdb94795b
commit
fe8ea56d9b
8 files changed
+335
-10
No files matched your search
@@ -2,6 +2,7 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
import { addToast } from '$lib/stores/toast.svelte';
|
import { addToast } from '$lib/stores/toast.svelte';
|
||||||
|
import { prepareUpload } from '$lib/uploads';
|
||||||
|
|
||||||
import Tabs from './Tabs.svelte';
|
import Tabs from './Tabs.svelte';
|
||||||
|
|
||||||
@@ -94,15 +95,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpload(e: Event) {
|
async function handleUpload(e: Event) {
|
||||||
|
if (isUploading) return;
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
isUploading = true;
|
isUploading = true;
|
||||||
try {
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', await prepareUpload(file));
|
||||||
const res = await fetch('/admin/api/upload', {
|
const res = await fetch('/admin/api/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
@@ -118,7 +119,7 @@
|
|||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
addToast('网络错误,上传失败', 'error');
|
addToast(err instanceof Error ? err.message : '图片处理或上传失败', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
isUploading = false;
|
isUploading = false;
|
||||||
input.value = '';
|
input.value = '';
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
Strikethrough
|
Strikethrough
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { addToast } from '$lib/stores/toast.svelte';
|
import { addToast } from '$lib/stores/toast.svelte';
|
||||||
|
import { prepareUpload } from '$lib/uploads';
|
||||||
import { imagePreviewPlugin } from '$lib/editor/imagePreviewPlugin';
|
import { imagePreviewPlugin } from '$lib/editor/imagePreviewPlugin';
|
||||||
import { tableFormatterPlugin } from '$lib/editor/tableFormatterPlugin';
|
import { tableFormatterPlugin } from '$lib/editor/tableFormatterPlugin';
|
||||||
|
|
||||||
@@ -85,15 +86,15 @@
|
|||||||
let isUploadingImage = $state(false);
|
let isUploadingImage = $state(false);
|
||||||
|
|
||||||
async function handleImageUpload(e: Event) {
|
async function handleImageUpload(e: Event) {
|
||||||
|
if (isUploadingImage) return;
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
isUploadingImage = true;
|
isUploadingImage = true;
|
||||||
try {
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', await prepareUpload(file));
|
||||||
const res = await fetch('/admin/api/upload', {
|
const res = await fetch('/admin/api/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
@@ -110,7 +111,7 @@
|
|||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
addToast('网络错误,上传失败', 'error');
|
addToast(err instanceof Error ? err.message : '图片处理或上传失败', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
isUploadingImage = false;
|
isUploadingImage = false;
|
||||||
input.value = '';
|
input.value = '';
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { convertToWebp, prepareUpload } from './uploads';
|
||||||
|
|
||||||
|
const pngHeader = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||||
|
const png = () => new File([new Uint8Array(pngHeader)], 'image.png', { type: 'image/png' });
|
||||||
|
|
||||||
|
function mockCanvas(blob: Blob | null = new Blob(['webp output'], { type: 'image/webp' })) {
|
||||||
|
const drawImage = vi.fn();
|
||||||
|
const toBlob = vi.fn((callback: BlobCallback) => callback(blob));
|
||||||
|
const canvas = { width: 0, height: 0, getContext: () => ({ drawImage }), toBlob };
|
||||||
|
vi.stubGlobal('document', { createElement: vi.fn(() => canvas) });
|
||||||
|
vi.stubGlobal(
|
||||||
|
'Image',
|
||||||
|
class {
|
||||||
|
naturalWidth = 120;
|
||||||
|
naturalHeight = 80;
|
||||||
|
decode = vi.fn().mockResolvedValue(undefined);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test');
|
||||||
|
const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||||
|
return { canvas, drawImage, toBlob, revoke };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('convertToWebp', () => {
|
||||||
|
it.each([
|
||||||
|
['photo.JPEG', 'image/jpeg', [0xff, 0xd8, 0xff]],
|
||||||
|
['image.png', 'image/png', pngHeader]
|
||||||
|
])('converts %s with quality 0.8 and releases browser resources', async (name, type, bytes) => {
|
||||||
|
const { canvas, drawImage, toBlob, revoke } = mockCanvas();
|
||||||
|
const file = new File([new Uint8Array(bytes)], name, { type, lastModified: 123 });
|
||||||
|
const output = await convertToWebp(file);
|
||||||
|
expect(output.name).toBe(name.replace(/\.[^.]+$/, '.webp'));
|
||||||
|
expect(output.type).toBe('image/webp');
|
||||||
|
expect(output.lastModified).toBe(123);
|
||||||
|
expect(await output.text()).toBe('webp output');
|
||||||
|
expect(toBlob).toHaveBeenCalledWith(expect.any(Function), 'image/webp', 0.8);
|
||||||
|
expect(drawImage).toHaveBeenCalledWith(expect.any(Object), 0, 0);
|
||||||
|
expect(revoke).toHaveBeenCalledWith('blob:test');
|
||||||
|
expect(canvas.width).toBe(0);
|
||||||
|
expect(canvas.height).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['image/gif', 'image/webp', 'application/pdf', 'application/zip'])(
|
||||||
|
'keeps %s byte-for-byte unchanged',
|
||||||
|
async (type) => {
|
||||||
|
const file = new File(['original'], 'file', { type });
|
||||||
|
expect(await convertToWebp(file)).toBe(file);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('preserves animated PNG', async () => {
|
||||||
|
const file = new File(
|
||||||
|
[
|
||||||
|
new Uint8Array([
|
||||||
|
...pngHeader,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
8,
|
||||||
|
97,
|
||||||
|
99,
|
||||||
|
84,
|
||||||
|
76,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
])
|
||||||
|
],
|
||||||
|
'animated.png',
|
||||||
|
{ type: 'image/png' }
|
||||||
|
);
|
||||||
|
expect(await convertToWebp(file)).toBe(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not convert spoofed MIME types or mismatched extensions', async () => {
|
||||||
|
for (const file of [
|
||||||
|
new File(['<svg/>'], 'image.png', { type: 'image/png' }),
|
||||||
|
new File([new Uint8Array(pngHeader)], 'image.jpg', { type: 'image/png' })
|
||||||
|
])
|
||||||
|
expect(await convertToWebp(file)).toBe(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([null, new Blob(['PNG fallback'], { type: 'image/png' })])(
|
||||||
|
'rejects failed or unsupported WebP encoding and cleans up',
|
||||||
|
async (blob) => {
|
||||||
|
const { revoke } = mockCanvas(blob);
|
||||||
|
await expect(convertToWebp(png())).rejects.toThrow('不支持 WebP');
|
||||||
|
expect(revoke).toHaveBeenCalledOnce();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('rejects decoded images that exceed the pixel limit', async () => {
|
||||||
|
const { toBlob, revoke } = mockCanvas();
|
||||||
|
vi.stubGlobal(
|
||||||
|
'Image',
|
||||||
|
class {
|
||||||
|
naturalWidth = 10000;
|
||||||
|
naturalHeight = 10000;
|
||||||
|
decode = vi.fn().mockResolvedValue(undefined);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await expect(convertToWebp(png())).rejects.toThrow('3200 万像素');
|
||||||
|
expect(toBlob).not.toHaveBeenCalled();
|
||||||
|
expect(revoke).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('prepareUpload', () => {
|
||||||
|
it('reads fresh settings and converts only when enabled', async () => {
|
||||||
|
mockCanvas();
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(Response.json({ autoWebp: false }))
|
||||||
|
.mockResolvedValueOnce(Response.json({ autoWebp: true }));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const file = png();
|
||||||
|
expect(await prepareUpload(file)).toBe(file);
|
||||||
|
expect((await prepareUpload(file)).type).toBe('image/webp');
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/admin/api/upload', { cache: 'no-store' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops uploading when settings cannot be read', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 500 })));
|
||||||
|
await expect(prepareUpload(png())).rejects.toThrow('读取图片上传设置失败');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects original files above 10MB before converting or fetching settings', async () => {
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const file = new File([new Uint8Array(10 * 1024 * 1024 + 1)], 'large.png', {
|
||||||
|
type: 'image/png'
|
||||||
|
});
|
||||||
|
await expect(prepareUpload(file)).rejects.toThrow('10MB');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||||
|
const WEBP_QUALITY = 0.8;
|
||||||
|
|
||||||
|
// Inspect PNG chunks, rather than searching compressed pixels for an acTL byte sequence.
|
||||||
|
function isAnimatedPng(bytes: Uint8Array): boolean {
|
||||||
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
|
for (let offset = 8; offset + 12 <= bytes.length;) {
|
||||||
|
const length = view.getUint32(offset);
|
||||||
|
const type = String.fromCharCode(...bytes.subarray(offset + 4, offset + 8));
|
||||||
|
if (type === 'acTL') return true;
|
||||||
|
if (type === 'IDAT' || type === 'IEND') return false;
|
||||||
|
offset += 12 + length;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function convertToWebp(file: File): Promise<File> {
|
||||||
|
const type = file.type.toLowerCase();
|
||||||
|
const extension = file.name.split('.').pop()?.toLowerCase();
|
||||||
|
if (!(
|
||||||
|
(type === 'image/jpeg' && /^(jpg|jpeg)$/.test(extension || '')) ||
|
||||||
|
(type === 'image/png' && extension === 'png')
|
||||||
|
)) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_FILE_SIZE) throw new Error('文件大小不能超过 10MB');
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
// Leave invalid uploads untouched so the server can reject them using the original metadata.
|
||||||
|
if (type === 'image/jpeg') {
|
||||||
|
if (bytes[0] !== 0xff || bytes[1] !== 0xd8 || bytes[2] !== 0xff) return file;
|
||||||
|
} else {
|
||||||
|
if (![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((b, i) => bytes[i] === b)) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
if (isAnimatedPng(bytes)) return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
try {
|
||||||
|
const image = new Image();
|
||||||
|
image.src = url;
|
||||||
|
try {
|
||||||
|
await image.decode();
|
||||||
|
} catch {
|
||||||
|
throw new Error('无法读取图片,请确认文件完整或关闭自动 WebP 转换后重试');
|
||||||
|
}
|
||||||
|
if (image.naturalWidth * image.naturalHeight > 32_000_000) {
|
||||||
|
throw new Error('图片像素过大,请缩小至 3200 万像素以内后重试');
|
||||||
|
}
|
||||||
|
canvas.width = image.naturalWidth;
|
||||||
|
canvas.height = image.naturalHeight;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) throw new Error('当前浏览器无法处理图片');
|
||||||
|
context.drawImage(image, 0, 0);
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) => {
|
||||||
|
canvas.toBlob(resolve, 'image/webp', WEBP_QUALITY);
|
||||||
|
});
|
||||||
|
if (!blob || blob.type !== 'image/webp') {
|
||||||
|
throw new Error('当前浏览器不支持 WebP 转换,请更换浏览器或关闭自动转换');
|
||||||
|
}
|
||||||
|
if (blob.size > MAX_FILE_SIZE) throw new Error('转换后的图片超过 10MB,请缩小图片后重试');
|
||||||
|
return new File([blob], file.name.replace(/\.[^.]+$/, '.webp'), {
|
||||||
|
type: 'image/webp',
|
||||||
|
lastModified: file.lastModified
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
canvas.width = canvas.height = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareUpload(file: File): Promise<File> {
|
||||||
|
if (file.size > MAX_FILE_SIZE) throw new Error('文件大小不能超过 10MB');
|
||||||
|
if (!['image/jpeg', 'image/png'].includes(file.type.toLowerCase())) return file;
|
||||||
|
|
||||||
|
// Read at upload time so settings saved in another tab also take effect immediately.
|
||||||
|
const response = await fetch('/admin/api/upload', { cache: 'no-store' });
|
||||||
|
if (!response.ok) throw new Error('读取图片上传设置失败,请稍后重试');
|
||||||
|
const settings = (await response.json()) as { autoWebp: boolean };
|
||||||
|
return settings.autoWebp ? convertToWebp(file) : file;
|
||||||
|
}
|
||||||
@@ -2,6 +2,24 @@ import { parseAndValidateUpload, storeUpload, UploadValidationError } from '$lib
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ platform }) => {
|
||||||
|
const db = platform?.env.DB;
|
||||||
|
if (!db) return json({ error: '数据库未连接' }, { status: 500 });
|
||||||
|
try {
|
||||||
|
const setting = await db
|
||||||
|
.prepare('SELECT value FROM settings WHERE key = ?')
|
||||||
|
.bind('upload.auto_webp')
|
||||||
|
.first<{ value: string }>();
|
||||||
|
return json(
|
||||||
|
{ autoWebp: setting?.value === 'true' },
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } }
|
||||||
|
);
|
||||||
|
} catch (cause) {
|
||||||
|
console.error('Failed to read upload settings:', cause);
|
||||||
|
return json({ error: '读取图片上传设置失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request, platform }) => {
|
export const POST: RequestHandler = async ({ request, platform }) => {
|
||||||
const db = platform?.env.DB;
|
const db = platform?.env.DB;
|
||||||
const r2 = platform?.env.R2;
|
const r2 = platform?.env.R2;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
import { addToast } from '$lib/stores/toast.svelte';
|
import { addToast } from '$lib/stores/toast.svelte';
|
||||||
import { confirmState } from '$lib/stores/confirm.svelte';
|
import { confirmState } from '$lib/stores/confirm.svelte';
|
||||||
|
import { prepareUpload } from '$lib/uploads';
|
||||||
|
|
||||||
let { data, form } = $props<{ data: PageData; form: ActionData }>();
|
let { data, form } = $props<{ data: PageData; form: ActionData }>();
|
||||||
|
|
||||||
@@ -92,15 +93,36 @@
|
|||||||
method="POST"
|
method="POST"
|
||||||
action="?/upload"
|
action="?/upload"
|
||||||
enctype="multipart/form-data"
|
enctype="multipart/form-data"
|
||||||
use:enhance={() => {
|
use:enhance={async ({ formData, cancel }) => {
|
||||||
|
if (isUploading) {
|
||||||
|
cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
isUploading = true;
|
isUploading = true;
|
||||||
|
try {
|
||||||
|
const file = formData.get('file');
|
||||||
|
if (file instanceof globalThis.File) {
|
||||||
|
formData.set('file', await prepareUpload(file));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
cancel();
|
||||||
|
isUploading = false;
|
||||||
|
fileInput.value = '';
|
||||||
|
addToast(err instanceof Error ? err.message : '图片处理失败', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
return async ({ update }) => {
|
return async ({ update }) => {
|
||||||
isUploading = false;
|
isUploading = false;
|
||||||
await update();
|
await update();
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button type="button" class="upload-box" onclick={() => fileInput.click()}>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="upload-box"
|
||||||
|
disabled={isUploading}
|
||||||
|
onclick={() => fileInput.click()}
|
||||||
|
>
|
||||||
<Upload size={48} stroke-width={1.5} aria-hidden="true" />
|
<Upload size={48} stroke-width={1.5} aria-hidden="true" />
|
||||||
<h3>{isUploading ? '正在上传...' : '点击或拖拽文件上传'}</h3>
|
<h3>{isUploading ? '正在上传...' : '点击或拖拽文件上传'}</h3>
|
||||||
<p>存储于 Cloudflare R2</p>
|
<p>存储于 Cloudflare R2</p>
|
||||||
|
|||||||
@@ -28,6 +28,27 @@ export const load: PageServerLoad = async ({ locals, platform }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
|
updateUploads: async ({ request, platform }) => {
|
||||||
|
const db = platform?.env?.DB;
|
||||||
|
if (!db) return fail(500, { error: '数据库未连接' });
|
||||||
|
|
||||||
|
const data = await request.formData();
|
||||||
|
const enabled = data.get('upload_auto_webp') === 'true';
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO settings (key, value, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`
|
||||||
|
)
|
||||||
|
.bind('upload.auto_webp', String(enabled))
|
||||||
|
.run();
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update upload settings', err);
|
||||||
|
return fail(500, { error: '保存图片上传设置失败' });
|
||||||
|
}
|
||||||
|
},
|
||||||
updateSiteInfo: async ({ request, platform }) => {
|
updateSiteInfo: async ({ request, platform }) => {
|
||||||
const db = platform?.env?.DB;
|
const db = platform?.env?.DB;
|
||||||
if (!db) return fail(500, { error: '数据库未连接' });
|
if (!db) return fail(500, { error: '数据库未连接' });
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
let apiToken = $state(initialData.settings['agent.api_token'] || '');
|
let apiToken = $state(initialData.settings['agent.api_token'] || '');
|
||||||
let showApiToken = $state(false);
|
let showApiToken = $state(false);
|
||||||
|
let autoWebp = $state(initialData.settings['upload.auto_webp'] === 'true');
|
||||||
|
|
||||||
function generateApiToken() {
|
function generateApiToken() {
|
||||||
const randomPart = Array.from(crypto.getRandomValues(new Uint8Array(16)))
|
const randomPart = Array.from(crypto.getRandomValues(new Uint8Array(16)))
|
||||||
@@ -143,6 +144,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card full-width">
|
||||||
|
<h3>图片上传</h3>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/updateUploads"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ update }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="alias-form"
|
||||||
|
>
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input type="checkbox" name="upload_auto_webp" value="true" bind:checked={autoWebp} />
|
||||||
|
<span class="toggle-text">上传图片时自动转换为 WebP</span>
|
||||||
|
</label>
|
||||||
|
<p class="desc">
|
||||||
|
默认关闭。开启后,后台各入口上传的 JPEG / PNG 图片会在浏览器中以 80% 质量转换为
|
||||||
|
WebP,保持原尺寸及透明背景。GIF、动态 PNG 和已有 WebP 保持原格式。
|
||||||
|
仅影响新上传图片;直接调用上传 API 的客户端需自行转换。
|
||||||
|
</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<Button type="submit" variant="primary">保存上传设置</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-card full-width">
|
<div class="settings-card full-width">
|
||||||
<h3>API 与 AI 集成</h3>
|
<h3>API 与 AI 集成</h3>
|
||||||
<p class="desc">
|
<p class="desc">
|
||||||
|
|||||||
Reference in new issue
Block a user