From b469b609976986616877ba1d1f9bfb1e60343d4f Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:59:12 +0800 Subject: [PATCH] fix(avatar): reuse open-source image cropping component Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../design.md | 4 + .../add-skill-avatars-and-moderation/tasks.md | 8 ++ web/package.json | 1 + web/pnpm-lock.yaml | 12 ++ .../skill/skill-avatar-editor.test.tsx | 105 ++++++++++++++++-- .../features/skill/skill-avatar-editor.tsx | 86 +++++++++----- web/src/i18n/locales/en.json | 13 ++- web/src/i18n/locales/ru.json | 13 ++- web/src/i18n/locales/zh.json | 13 ++- 9 files changed, 214 insertions(+), 41 deletions(-) diff --git a/openspec/changes/add-skill-avatars-and-moderation/design.md b/openspec/changes/add-skill-avatars-and-moderation/design.md index 3fb7899c..1796a5df 100644 --- a/openspec/changes/add-skill-avatars-and-moderation/design.md +++ b/openspec/changes/add-skill-avatars-and-moderation/design.md @@ -38,3 +38,7 @@ 原验收只覆盖详情页独立提交流程,不能证明上述新增/更新流程;本轮新增/更新及独立维护流程按对应测试和浏览器验收重新验证。真实供应商调用仍未验证。 同浏览器的发布、独立维护和审核决定成功后,须等待刷新技能展示、对应所有者头像状态、头像审核队列及技能内容审核队列缓存;不以等待缓存过期替代状态更新。回归以真实 QueryClient 下旧状态变为提交后的状态为 Oracle,并验证无关技能的所有者缓存不被失效。 + +## 裁剪组件选型调整(2026-10-13) + +头像文件选择复用已有 `react-dropzone`,裁剪交互采用 `react-image-crop`(ISC,兼容项目 React 版本)。方形选区的拖动、缩放选区、触屏和键盘行为交由开源组件处理,移除自写居中裁剪与缩放交互。仅保留选区图像导出及私有上传/提交状态衔接,不改变审核、引用消费、保留/替换/移除规则。组件适配须验证非居中选区的实际导出结果、取消与迟到响应隔离,以及新增/更新/独立维护入口。 diff --git a/openspec/changes/add-skill-avatars-and-moderation/tasks.md b/openspec/changes/add-skill-avatars-and-moderation/tasks.md index 9ee02a05..bd605423 100644 --- a/openspec/changes/add-skill-avatars-and-moderation/tasks.md +++ b/openspec/changes/add-skill-avatars-and-moderation/tasks.md @@ -39,3 +39,11 @@ 真实讯飞/阿里云接入是独立的部署联调项,需要已开通能力及部署凭证;模拟测试不得标记为真实调用通过。 本轮浏览器验收使用 Chromium 145 与 Playwright 1.58.2,分别通过新增/更新/独立维护头像生命周期及无头像正常发布与下载流程;前端 958 项测试、类型检查、Lint、生产构建、真实 PostgreSQL 临时上传与原子提交回归通过。CI 状态及完整验收证据随 PR #947 更新,未自动合并。 + +## 6. 复用开源头像裁剪组件(2026-10-13,实施中) + +前述验收对应原居中裁剪实现;本节完成后重新验证受影响的交互及预览,不把旧结果视为新组件验证。 + +- [x] 6.1 复用 react-dropzone 文件选择,并用 react-image-crop 替换自写裁剪交互,保留既有私有上传与头像提交状态。 +- [x] 6.2 验证可移动方形选区、键盘、实际导出位置、文件错误、取消/迟到响应及上传失败重试,完成独立复审。 +- [ ] 6.3 构建当前提交的唯一预览,实际验证新增/更新/独立维护并更新 PR #947 验收记录。 diff --git a/web/package.json b/web/package.json index d131b5e4..422cc977 100644 --- a/web/package.json +++ b/web/package.json @@ -58,6 +58,7 @@ "react-dom": "^19.0.0", "react-dropzone": "^15.0.0", "react-i18next": "^16.5.8", + "react-image-crop": "11.1.2", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", "rehype-sanitize": "^6.0.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index a14647c0..2a4957f4 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -79,6 +79,9 @@ importers: react-i18next: specifier: ^16.5.8 version: 16.5.8(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + react-image-crop: + specifier: 11.1.2 + version: 11.1.2(react@19.2.4) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.4) @@ -2915,6 +2918,11 @@ packages: typescript: optional: true + react-image-crop@11.1.2: + resolution: {integrity: sha512-+0Pc2fxpwKL4u4oLmdKBw8XSwUceFbXbKEHvFOlsl/MGB1OVNic4uBlAPmEHGXYgoJIq+b63xHbc/aJMG0AVkA==} + peerDependencies: + react: '>=16.13.1' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -6461,6 +6469,10 @@ snapshots: react-dom: 19.2.4(react@19.2.4) typescript: 5.9.3 + react-image-crop@11.1.2(react@19.2.4): + dependencies: + react: 19.2.4 + react-is@16.13.1: {} react-is@17.0.2: {} diff --git a/web/src/features/skill/skill-avatar-editor.test.tsx b/web/src/features/skill/skill-avatar-editor.test.tsx index c7a06b62..d67a0b39 100644 --- a/web/src/features/skill/skill-avatar-editor.test.tsx +++ b/web/src/features/skill/skill-avatar-editor.test.tsx @@ -6,8 +6,10 @@ vi.mock('@/api/client', () => ({ skillAvatarApi: api, buildApiUrl: (url: string) vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })) import { SkillAvatarEditor } from './skill-avatar-editor' -function selectImage() { +async function selectImage() { fireEvent.change(screen.getByLabelText('skillAvatar.choose'), { target: { files: [new File(['image'], 'avatar.png', { type: 'image/png' })] } }) + const image = await screen.findByAltText('skillAvatar.cropPreview') + fireEvent.load(image) } function deferred() { let resolve!: (value: T) => void @@ -19,19 +21,100 @@ const uploaded = { uploadId: 'private-upload-1', previewUrl: '/private/preview', describe('SkillAvatarEditor staged uploads', () => { beforeEach(() => { api.stage.mockReset() - vi.stubGlobal('Image', class { src = ''; naturalWidth = 1024; naturalHeight = 768; decode() { return Promise.resolve() } }) + vi.stubGlobal('PointerEvent', MouseEvent) + vi.spyOn(HTMLImageElement.prototype, 'naturalWidth', 'get').mockReturnValue(1024) + vi.spyOn(HTMLImageElement.prototype, 'naturalHeight', 'get').mockReturnValue(768) + vi.spyOn(HTMLImageElement.prototype, 'width', 'get').mockReturnValue(256) + vi.spyOn(HTMLImageElement.prototype, 'height', 'get').mockReturnValue(192) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, width: 256, height: 192, top: 0, left: 0, right: 256, bottom: 192, toJSON: () => ({}) }) vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:chosen'), revokeObjectURL: vi.fn() }) - vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ drawImage: vi.fn() } as unknown as CanvasRenderingContext2D) + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ drawImage: vi.fn(), scale: vi.fn(), save: vi.fn(), translate: vi.fn(), rotate: vi.fn(), restore: vi.fn() } as unknown as CanvasRenderingContext2D) vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(callback => callback(new Blob(['cropped'], { type: 'image/png' }))) }) afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() }) + it('exports the moved library selection rather than always cropping the image center', async () => { + api.stage.mockResolvedValue(uploaded) + render() + await selectImage() + const area = screen.getByRole('group', { name: 'skillAvatar.cropArea' }) + const initialLeft = area.style.left + fireEvent.keyDown(area, { key: 'ArrowRight', shiftKey: true }) + expect(area.style.left).not.toBe(initialLeft) + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' })) + await waitFor(() => expect(api.stage).toHaveBeenCalledOnce()) + const context = vi.mocked(HTMLCanvasElement.prototype.getContext).mock.results[0].value as unknown as { translate: ReturnType; drawImage: ReturnType } + // Initial square is 576px at x=224. Library Shift+Right moves 10 display px = 40 source px. + expect(context.translate).toHaveBeenCalledWith(-264, -96) + expect(context.drawImage.mock.calls.some(args => args.length === 5 && args.slice(1).join(',') === '0,0,512,512')).toBe(true) + expect(api.stage.mock.calls[0][0].type).toBe('image/png') + }) + + it('exports a dragged selection with the library pointer handlers', async () => { + api.stage.mockResolvedValue(uploaded) + render() + await selectImage() + const area = screen.getByRole('group', { name: 'skillAvatar.cropArea' }) + fireEvent.pointerDown(area, { clientX: 128, clientY: 96 }) + fireEvent.pointerMove(document, { clientX: 148, clientY: 106 }) + fireEvent.pointerUp(document) + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' })) + await waitFor(() => expect(api.stage).toHaveBeenCalledOnce()) + const context = vi.mocked(HTMLCanvasElement.prototype.getContext).mock.results[0].value as unknown as { translate: ReturnType } + expect(context.translate.mock.calls[0][0]).toBeCloseTo(-304) + expect(context.translate.mock.calls[0][1]).toBeCloseTo(-136) + }) + + it('does not upload if canceled while the PNG conversion is still in progress', async () => { + const change = vi.fn() + let finish!: BlobCallback + vi.mocked(HTMLCanvasElement.prototype.toBlob).mockImplementation(callback => { finish = callback }) + render() + await selectImage() + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' })) + await waitFor(() => expect(finish).toBeTypeOf('function')) + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.discardChange' })) + finish(new Blob(['cropped'], { type: 'image/png' })) + await waitFor(() => expect(change).toHaveBeenLastCalledWith({ action: 'KEEP', ready: true })) + expect(api.stage).not.toHaveBeenCalled() + }) + + it('keeps the file chooser keyboard accessible and disables it during an upload', async () => { + api.stage.mockReturnValue(new Promise(() => {})) + render() + const input = screen.getByLabelText('skillAvatar.choose') as HTMLInputElement + expect(input.tabIndex).toBe(0) + expect(input.disabled).toBe(false) + await selectImage() + fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' })) + expect(input.disabled).toBe(true) + }) + + it('rejects images above 2MB before attempting an upload', async () => { + const change = vi.fn() + render() + fireEvent.change(screen.getByLabelText('skillAvatar.choose'), { target: { files: [new File([new Uint8Array(2 * 1024 * 1024 + 1)], 'avatar.png', { type: 'image/png' })] } }) + expect((await screen.findByRole('alert')).textContent).toBe('skillAvatar.invalid') + expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', ready: false }) + expect(api.stage).not.toHaveBeenCalled() + }) + + it('requires the selected image to load before cropping and reports decode errors', async () => { + render() + fireEvent.change(screen.getByLabelText('skillAvatar.choose'), { target: { files: [new File(['image'], 'avatar.png', { type: 'image/png' })] } }) + const image = await screen.findByAltText('skillAvatar.cropPreview') + expect((screen.getByRole('button', { name: 'skillAvatar.cropUpload' }) as HTMLButtonElement).disabled).toBe(true) + fireEvent.error(image) + expect(screen.getByRole('alert').textContent).toBe('skillAvatar.invalid') + expect(api.stage).not.toHaveBeenCalled() + }) + it('requires upload before submit and stages the cropped file without associating a skill', async () => { const change = vi.fn() const task = deferred() api.stage.mockReturnValue(task.promise) render() - selectImage() + await selectImage() expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', ready: false }) fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' })) await waitFor(() => expect(api.stage).toHaveBeenCalledOnce()) @@ -42,11 +125,11 @@ describe('SkillAvatarEditor staged uploads', () => { expect(screen.getByAltText('skillAvatar.cropPreview').getAttribute('src')).toBe(uploaded.previewUrl) }) - it('blocks invalid selections until canceled without touching the active avatar', () => { + it.each([['bad.svg', 'image/svg+xml'], ['bad.png', 'image/gif']])('blocks invalid selection %s until canceled without touching the active avatar', async (filename, mime) => { const change = vi.fn() render() - fireEvent.change(screen.getByLabelText('skillAvatar.choose'), { target: { files: [new File(['bad'], 'bad.svg', { type: 'image/svg+xml' })] } }) - expect(screen.getByRole('alert').textContent).toBe('skillAvatar.invalid') + fireEvent.change(screen.getByLabelText('skillAvatar.choose'), { target: { files: [new File(['bad'], filename, { type: mime })] } }) + expect((await screen.findByRole('alert')).textContent).toBe('skillAvatar.invalid') expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', ready: false }) expect(api.stage).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.discardChange' })) @@ -58,7 +141,7 @@ describe('SkillAvatarEditor staged uploads', () => { const change = vi.fn() api.stage.mockRejectedValueOnce(new Error('storage unavailable')).mockResolvedValueOnce(uploaded) render() - selectImage() + await selectImage() fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' })) expect((await screen.findByRole('alert')).textContent).toBe('storage unavailable') expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', ready: false }) @@ -71,7 +154,7 @@ describe('SkillAvatarEditor staged uploads', () => { const task = deferred() api.stage.mockReturnValue(task.promise) render() - selectImage() + await selectImage() fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' })) await waitFor(() => expect(api.stage).toHaveBeenCalledOnce()) fireEvent.click(screen.getByRole('button', { name: `skillAvatar.${action}` })) @@ -85,10 +168,10 @@ describe('SkillAvatarEditor staged uploads', () => { const change = vi.fn() api.stage.mockResolvedValue(uploaded) render() - selectImage() + await selectImage() fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.cropUpload' })) await waitFor(() => expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', uploadId: uploaded.uploadId, ready: true })) - selectImage() + await selectImage() expect(change).toHaveBeenLastCalledWith({ action: 'REPLACE', ready: false }) expect(screen.getByRole('status').textContent).toBe('skillAvatar.uploadStates.SELECTED') }) diff --git a/web/src/features/skill/skill-avatar-editor.tsx b/web/src/features/skill/skill-avatar-editor.tsx index 70757bb6..c5e7923a 100644 --- a/web/src/features/skill/skill-avatar-editor.tsx +++ b/web/src/features/skill/skill-avatar-editor.tsx @@ -1,4 +1,7 @@ import { useEffect, useRef, useState } from 'react' +import ReactCrop, { centerCrop, makeAspectCrop, convertToPixelCrop, cropToCanvas, type PercentCrop } from 'react-image-crop' +import { useDropzone } from 'react-dropzone' +import 'react-image-crop/dist/ReactCrop.css' import { useTranslation } from 'react-i18next' import { buildApiUrl, skillAvatarApi } from '@/api/client' import { Button } from '@/shared/ui/button' @@ -8,16 +11,14 @@ import { SkillAvatar } from './skill-avatar' export type AvatarEdit = { action: 'KEEP' | 'REPLACE' | 'REMOVE'; uploadId?: string; ready: boolean } export const KEEP_AVATAR: AvatarEdit = { action: 'KEEP', ready: true } -async function cropSquare(source: string, zoom: number): Promise { - const image = new Image() - image.src = source - await image.decode() +async function exportCrop(image: HTMLImageElement, crop: PercentCrop): Promise { + const cropped = document.createElement('canvas') + await cropToCanvas(image, cropped, convertToPixelCrop(crop, image.width, image.height)) const canvas = document.createElement('canvas') canvas.width = canvas.height = 512 const context = canvas.getContext('2d') if (!context) throw new Error('Canvas unavailable') - const side = Math.min(image.naturalWidth, image.naturalHeight) / zoom - context.drawImage(image, (image.naturalWidth - side) / 2, (image.naturalHeight - side) / 2, side, side, 0, 0, 512, 512) + context.drawImage(cropped, 0, 0, 512, 512) return new Promise((resolve, reject) => canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('Image conversion failed')), 'image/png')) } @@ -27,12 +28,12 @@ export function SkillAvatarEditor({ name, currentUrl, canRemove = false, disable }) { const { t } = useTranslation() const [sourceUrl, setSourceUrl] = useState(null) - const [zoom, setZoom] = useState(1) + const [crop, setCrop] = useState() const [previewUrl, setPreviewUrl] = useState(null) const [state, setState] = useState<'KEEP' | 'SELECTED' | 'UPLOADING' | 'READY' | 'ERROR' | 'REMOVE'>('KEEP') const [error, setError] = useState('') const generation = useRef(0) - const input = useRef(null) + const image = useRef(null) useEffect(() => () => { generation.current += 1 }, []) useEffect(() => () => { if (sourceUrl) URL.revokeObjectURL(sourceUrl) }, [sourceUrl]) @@ -42,18 +43,21 @@ export function SkillAvatarEditor({ name, currentUrl, canRemove = false, disable setPreviewUrl(null) setError('') setState(remove ? 'REMOVE' : 'KEEP') - if (input.current) input.current.value = '' + setCrop(undefined) + if (inputRef.current) inputRef.current.value = '' onChange(remove ? { action: 'REMOVE', ready: true } : KEEP_AVATAR) } async function upload() { - if (!sourceUrl) return + if (!image.current || !crop?.width || !crop.height) return const attempt = ++generation.current setState('UPLOADING') setError('') onChange({ action: 'REPLACE', ready: false }) try { - const staged = await skillAvatarApi.stage(await cropSquare(sourceUrl, zoom)) + const blob = await exportCrop(image.current, crop) + if (attempt !== generation.current) return + const staged = await skillAvatarApi.stage(blob) if (attempt !== generation.current) return setPreviewUrl(staged.previewUrl) setState('READY') @@ -65,6 +69,29 @@ export function SkillAvatarEditor({ name, currentUrl, canRemove = false, disable } } + const { getRootProps, getInputProps, inputRef } = useDropzone({ + accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg'] }, + maxSize: 2 * 1024 * 1024, + maxFiles: 1, + multiple: false, + disabled: disabled || state === 'UPLOADING', + noClick: true, + noKeyboard: true, + onDrop: (accepted, rejected) => { + generation.current += 1 + setPreviewUrl(null) + setSourceUrl(null) + setCrop(undefined) + onChange({ action: 'REPLACE', ready: false }) + const file = accepted[0] + if (rejected.length || !file || !['image/png', 'image/jpeg'].includes(file.type)) { + setState('ERROR'); setError(t('skillAvatar.invalid')); return + } + setSourceUrl(URL.createObjectURL(file)) + setError(''); setState('SELECTED') + }, + }) + return

{t('skillAvatar.optionalTitle')}

{t('skillAvatar.stagedHelp')}

@@ -73,23 +100,28 @@ export function SkillAvatarEditor({ name, currentUrl, canRemove = false, disable : }

{t(`skillAvatar.uploadStates.${state}`)}

- { - const file = event.target.files?.[0] - if (!file) return - generation.current += 1 - setPreviewUrl(null) - setSourceUrl(null) - onChange({ action: 'REPLACE', ready: false }) - if (!['image/png', 'image/jpeg'].includes(file.type) || file.size > 2 * 1024 * 1024) { - setState('ERROR'); setError(t('skillAvatar.invalid')); return - } - setSourceUrl(URL.createObjectURL(file)) - setZoom(1); setError(''); setState('SELECTED') - }} /> +
+ +
{sourceUrl && state !== 'READY' &&
-
{t('skillAvatar.cropPreview')}
- - + setCrop(percentCrop)} + ariaLabels={{ + cropArea: t('skillAvatar.cropArea'), + nwDragHandle: t('skillAvatar.cropHandles.nw'), nDragHandle: t('skillAvatar.cropHandles.n'), + neDragHandle: t('skillAvatar.cropHandles.ne'), eDragHandle: t('skillAvatar.cropHandles.e'), + seDragHandle: t('skillAvatar.cropHandles.se'), sDragHandle: t('skillAvatar.cropHandles.s'), + swDragHandle: t('skillAvatar.cropHandles.sw'), wDragHandle: t('skillAvatar.cropHandles.w'), + }}> + {t('skillAvatar.cropPreview')} { + const { naturalWidth, naturalHeight } = event.currentTarget + setCrop(centerCrop(makeAspectCrop({ unit: '%', width: Math.min(75, 75 * naturalHeight / naturalWidth) }, 1, naturalWidth, naturalHeight), naturalWidth, naturalHeight)) + }} onError={() => { setState('ERROR'); setError(t('skillAvatar.invalid')); setCrop(undefined) }} /> + +

{t('skillAvatar.cropHelp')}

+
}
{state !== 'KEEP' && } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 100920db..3843f05c 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2620,7 +2620,18 @@ "candidatePreview": "Avatar candidate", "choose": "Choose avatar image", "cropPreview": "Crop preview", - "zoom": "Zoom", + "cropArea": "Crop area", + "cropHelp": "Drag the selection or its handles to adjust. Focus the selection and use arrow keys to move it.", + "cropHandles": { + "nw": "Resize top left", + "n": "Resize top", + "ne": "Resize top right", + "e": "Resize right", + "se": "Resize bottom right", + "s": "Resize bottom", + "sw": "Resize bottom left", + "w": "Resize left" + }, "submit": "Submit avatar", "remove": "Remove current avatar", "appeal": "Request review", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 3765d5bd..37ced423 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -2622,7 +2622,18 @@ "candidatePreview": "Новый аватар", "choose": "Выбрать изображение", "cropPreview": "Предпросмотр обрезки", - "zoom": "Масштаб", + "cropArea": "Область обрезки", + "cropHelp": "Перетащите выделение или его края. Выберите область и перемещайте её клавишами со стрелками.", + "cropHandles": { + "nw": "Изменить верхний левый угол", + "n": "Изменить верхний край", + "ne": "Изменить верхний правый угол", + "e": "Изменить правый край", + "se": "Изменить нижний правый угол", + "s": "Изменить нижний край", + "sw": "Изменить нижний левый угол", + "w": "Изменить левый край" + }, "submit": "Отправить аватар", "remove": "Удалить аватар", "appeal": "Запросить пересмотр", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index d2a4f3a4..6ee65bf7 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -2619,7 +2619,18 @@ "candidatePreview": "待审核头像", "choose": "选择头像图片", "cropPreview": "裁剪预览", - "zoom": "放大", + "cropArea": "裁剪区域", + "cropHelp": "拖动选区或边角调整;聚焦选区后可用方向键移动。", + "cropHandles": { + "nw": "调整左上角", + "n": "调整上边", + "ne": "调整右上角", + "e": "调整右边", + "se": "调整右下角", + "s": "调整下边", + "sw": "调整左下角", + "w": "调整左边" + }, "submit": "提交头像", "remove": "删除当前头像", "appeal": "申请复核",