feat(s3): 图片上传到 S3——设置/自动上传/右键单张/批量上传(docs/30)
Rust 新增 s3.rs:SigV4 签名 + PUT Object(reqwest blocking+rustls, 签名对照 AWS 官方示例向量逐字一致;同份代码对本地模拟 S3 端到端 实测通过)。命令 s3_upload_image / s3_test_connection(总数 30 对账)。 配置存 UiState.s3(本机偏好),设置弹窗含测试连接与自动上传开关。 未上传判定 = src 非 http/data/blob 的本地引用;上传成功后同 src 的 图片节点一次事务回换为 S3 地址,本地 .assets 文件保留。 入口:粘贴/插入后自动上传回换(扑空重试一次、失败必提示);右键 命中本地图片多一项「上传到 S3」;文件菜单 + 状态栏批量上传带进度 弹窗。验证:vitest 133+2、tsc、构建通过,设置弹窗截图核对。
This commit is contained in:
@@ -13,7 +13,7 @@ async function renderMenu(props: Record<string, unknown> = {}) {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<EditorContextMenu
|
||||
x={10} y={10} hasSelection={false} sourceMode={false}
|
||||
x={10} y={10} hasSelection={false} imageSrc={null} sourceMode={false}
|
||||
onAction={(a) => actions.push(a)}
|
||||
onClose={() => { closed += 1 }}
|
||||
{...props}
|
||||
@@ -38,6 +38,20 @@ describe('编辑区右键菜单', () => {
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('命中本地图片时多一项「上传到 S3」;网络图片不出现', async () => {
|
||||
const local = await renderMenu({ imageSrc: 'doc.assets/a.png' })
|
||||
const up = btn(local.container, '上传到 S3')
|
||||
expect(up).toBeTruthy()
|
||||
await act(async () => { up?.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||
expect(local.actions).toEqual(['uploadS3'])
|
||||
await act(async () => { local.root.unmount() })
|
||||
local.container.remove()
|
||||
const remote = await renderMenu({ imageSrc: 'https://cdn.example.com/a.png' })
|
||||
expect(btn(remote.container, '上传到 S3')).toBeUndefined()
|
||||
await act(async () => { remote.root.unmount() })
|
||||
remote.container.remove()
|
||||
})
|
||||
|
||||
it('源码模式只出编辑命令与「即时渲染」', async () => {
|
||||
const { container, root } = await renderMenu({ sourceMode: true, hasSelection: true })
|
||||
expect(btn(container, '加粗')).toBeUndefined()
|
||||
|
||||
53
tests/editor-images.test.ts
Normal file
53
tests/editor-images.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// 编辑器图片节点操作(docs/30):收集 src、按旧 src 回换、按 DOM 元素反查 src。
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createMarkdownEditor, getMarkdown, collectImageSrcs, replaceImageSrc, imageSrcAtElement } from '../src/editor/editor'
|
||||
|
||||
const DOC = [
|
||||
'# 图',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
async function makeEditor() {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const editor = createMarkdownEditor(root, DOC)
|
||||
await editor.create()
|
||||
return { root, editor }
|
||||
}
|
||||
|
||||
describe('editor image ops', () => {
|
||||
it('收集全部图片 src 并去重', async () => {
|
||||
const { root, editor } = await makeEditor()
|
||||
expect(collectImageSrcs(editor)).toEqual(['doc.assets/a.png', 'https://cdn.example.com/b.png'])
|
||||
await editor.destroy()
|
||||
root.remove()
|
||||
})
|
||||
|
||||
it('按旧 src 回换全部同名图片节点(含重复引用)', async () => {
|
||||
const { root, editor } = await makeEditor()
|
||||
const n = replaceImageSrc(editor, 'doc.assets/a.png', 'https://s3.example.com/a.png')
|
||||
expect(n).toBe(2)
|
||||
const md = getMarkdown(editor)
|
||||
expect(md).toContain('https://s3.example.com/a.png')
|
||||
expect(md).not.toContain('doc.assets/a.png')
|
||||
expect(collectImageSrcs(editor)).toEqual(['https://s3.example.com/a.png', 'https://cdn.example.com/b.png'])
|
||||
await editor.destroy()
|
||||
root.remove()
|
||||
})
|
||||
|
||||
it('按 <img> 元素反查节点 src;非图片元素返回 null', async () => {
|
||||
const { root, editor } = await makeEditor()
|
||||
const img = root.querySelector('img')
|
||||
expect(img).not.toBeNull()
|
||||
expect(imageSrcAtElement(editor, img as HTMLElement)).toBe('doc.assets/a.png')
|
||||
expect(imageSrcAtElement(editor, root)).toBeNull()
|
||||
await editor.destroy()
|
||||
root.remove()
|
||||
})
|
||||
})
|
||||
49
tests/s3-modal.test.tsx
Normal file
49
tests/s3-modal.test.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
// S3 设置弹窗(docs/30):字段回填、编辑后保存的载荷、自动上传开关。
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { S3SettingsModal } from '../src/components/S3SettingsModal'
|
||||
import { DEFAULT_S3_SETTINGS } from '../src/services/s3'
|
||||
|
||||
function setInput(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|
||||
setter?.call(input, value)
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
describe('S3SettingsModal', () => {
|
||||
it('填写桶/区域/密钥与自动上传开关后,保存回传完整设置', async () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
const onSave = vi.fn()
|
||||
await act(async () => {
|
||||
root.render(<S3SettingsModal initial={DEFAULT_S3_SETTINGS} onSave={onSave} onClose={() => undefined} />)
|
||||
})
|
||||
const inputs = [...container.querySelectorAll('input')]
|
||||
const byPlaceholder = (ph: string) => inputs.find((i) => i.placeholder === ph) as HTMLInputElement
|
||||
await act(async () => {
|
||||
setInput(byPlaceholder('us-east-1'), 'ap-northeast-1')
|
||||
setInput(byPlaceholder('my-images'), 'my-images')
|
||||
setInput(byPlaceholder('notes/'), 'blog/')
|
||||
})
|
||||
const textInputs = inputs.filter((i) => i.type === 'text' || i.type === 'password')
|
||||
// Access Key / Secret Key 是无 placeholder 的两个输入(第 4、5 个字段)
|
||||
await act(async () => {
|
||||
setInput(textInputs[3], 'AKID')
|
||||
setInput(textInputs[4], 'SECRET')
|
||||
})
|
||||
const checkbox = inputs.find((i) => i.type === 'checkbox') as HTMLInputElement
|
||||
await act(async () => { checkbox.click() })
|
||||
const saveBtn = [...container.querySelectorAll('button')].find((b) => b.textContent === '保存') as HTMLButtonElement
|
||||
await act(async () => { saveBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
const saved = onSave.mock.calls[0][0]
|
||||
expect(saved).toMatchObject({
|
||||
region: 'ap-northeast-1', bucket: 'my-images', accessKey: 'AKID', secretKey: 'SECRET',
|
||||
prefix: 'blog/', autoUpload: true,
|
||||
})
|
||||
await act(async () => { root.unmount() })
|
||||
container.remove()
|
||||
})
|
||||
})
|
||||
41
tests/s3.test.ts
Normal file
41
tests/s3.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// S3 前端逻辑(docs/30):本地图片识别、配置完整性、相对路径拼绝对路径、对象文件名。
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isLocalImageSrc, isS3Configured, resolveDocRelativePath, imageFileName, DEFAULT_S3_SETTINGS } from '../src/services/s3'
|
||||
|
||||
describe('isLocalImageSrc', () => {
|
||||
it('相对路径算本地;http/data/blob 不算', () => {
|
||||
expect(isLocalImageSrc('doc.assets/a.png')).toBe(true)
|
||||
expect(isLocalImageSrc('../shared/a.png')).toBe(true)
|
||||
expect(isLocalImageSrc('https://cdn.example.com/a.png')).toBe(false)
|
||||
expect(isLocalImageSrc('http://x/a.png')).toBe(false)
|
||||
expect(isLocalImageSrc('data:image/png;base64,xx')).toBe(false)
|
||||
expect(isLocalImageSrc('blob:abc')).toBe(false)
|
||||
expect(isLocalImageSrc('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isS3Configured', () => {
|
||||
it('四项必填齐才算配置过', () => {
|
||||
expect(isS3Configured(DEFAULT_S3_SETTINGS)).toBe(false)
|
||||
expect(isS3Configured({ ...DEFAULT_S3_SETTINGS, bucket: 'b', region: 'r', accessKey: 'a', secretKey: 's' })).toBe(true)
|
||||
expect(isS3Configured({ ...DEFAULT_S3_SETTINGS, bucket: 'b', region: 'r', accessKey: 'a' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveDocRelativePath', () => {
|
||||
it('文档同目录 .assets 拼接(含 Windows 路径与 ..)', () => {
|
||||
expect(resolveDocRelativePath('D:/notes/todo.md', 'todo.assets/a.png')).toBe('D:/notes/todo.assets/a.png')
|
||||
expect(resolveDocRelativePath('D:\\notes\\todo.md', 'todo.assets\\a.png')).toBe('D:/notes/todo.assets/a.png')
|
||||
expect(resolveDocRelativePath('/home/u/notes/todo.md', '../shared/a.png')).toBe('/home/u/shared/a.png')
|
||||
expect(resolveDocRelativePath('/home/u/notes/todo.md', 'a%20b.png')).toBe('/home/u/notes/a b.png')
|
||||
expect(resolveDocRelativePath('/home/u/notes/todo.md', 'C:/abs/a.png')).toBe('C:/abs/a.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('imageFileName', () => {
|
||||
it('取文件名(含解码与反斜杠)', () => {
|
||||
expect(imageFileName('doc.assets/图 1.png')).toBe('图 1.png')
|
||||
expect(imageFileName('a%20b.png')).toBe('a b.png')
|
||||
expect(imageFileName('x\\y\\z.jpg')).toBe('z.jpg')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user