fix(avatar): reuse open-source image cropping component

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-13 13:59:12 +08:00
parent 52f694f0b6
commit b469b60997
9 changed files with 214 additions and 41 deletions

View file

@ -38,3 +38,7 @@
原验收只覆盖详情页独立提交流程,不能证明上述新增/更新流程;本轮新增/更新及独立维护流程按对应测试和浏览器验收重新验证。真实供应商调用仍未验证。
同浏览器的发布、独立维护和审核决定成功后,须等待刷新技能展示、对应所有者头像状态、头像审核队列及技能内容审核队列缓存;不以等待缓存过期替代状态更新。回归以真实 QueryClient 下旧状态变为提交后的状态为 Oracle,并验证无关技能的所有者缓存不被失效。
## 裁剪组件选型调整(2026-10-13)
头像文件选择复用已有 `react-dropzone`,裁剪交互采用 `react-image-crop`(ISC,兼容项目 React 版本)。方形选区的拖动、缩放选区、触屏和键盘行为交由开源组件处理,移除自写居中裁剪与缩放交互。仅保留选区图像导出及私有上传/提交状态衔接,不改变审核、引用消费、保留/替换/移除规则。组件适配须验证非居中选区的实际导出结果、取消与迟到响应隔离,以及新增/更新/独立维护入口。

View file

@ -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 验收记录。

View file

@ -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",

12
web/pnpm-lock.yaml generated
View file

@ -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: {}

View file

@ -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<T>() {
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(<SkillAvatarEditor name="Foo" onChange={vi.fn()} />)
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<typeof vi.fn>; drawImage: ReturnType<typeof vi.fn> }
// 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(<SkillAvatarEditor name="Foo" onChange={vi.fn()} />)
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<typeof vi.fn> }
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(<SkillAvatarEditor name="Foo" onChange={change} />)
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(<SkillAvatarEditor name="Foo" onChange={vi.fn()} />)
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(<SkillAvatarEditor name="Foo" onChange={change} />)
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(<SkillAvatarEditor name="Foo" onChange={vi.fn()} />)
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<typeof uploaded>()
api.stage.mockReturnValue(task.promise)
render(<SkillAvatarEditor name="Foo" currentUrl="/active" onChange={change} />)
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(<SkillAvatarEditor name="Foo" currentUrl="/active" onChange={change} />)
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(<SkillAvatarEditor name="Foo" onChange={change} />)
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<typeof uploaded>()
api.stage.mockReturnValue(task.promise)
render(<SkillAvatarEditor name="Foo" currentUrl="/active" canRemove onChange={change} />)
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(<SkillAvatarEditor name="Foo" onChange={change} />)
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')
})

View file

@ -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<Blob> {
const image = new Image()
image.src = source
await image.decode()
async function exportCrop(image: HTMLImageElement, crop: PercentCrop): Promise<Blob> {
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<string | null>(null)
const [zoom, setZoom] = useState(1)
const [crop, setCrop] = useState<PercentCrop>()
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [state, setState] = useState<'KEEP' | 'SELECTED' | 'UPLOADING' | 'READY' | 'ERROR' | 'REMOVE'>('KEEP')
const [error, setError] = useState('')
const generation = useRef(0)
const input = useRef<HTMLInputElement>(null)
const image = useRef<HTMLImageElement>(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 <section className="space-y-3" aria-label={t('skillAvatar.title')}>
<h2 className="font-semibold">{t('skillAvatar.optionalTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('skillAvatar.stagedHelp')}</p>
@ -73,23 +100,28 @@ export function SkillAvatarEditor({ name, currentUrl, canRemove = false, disable
: <SkillAvatar name={name} url={state === 'REMOVE' ? null : currentUrl} size="h-20 w-20 text-2xl" />}
<p role="status" className="text-sm text-muted-foreground">{t(`skillAvatar.uploadStates.${state}`)}</p>
</div>
<Input ref={input} type="file" disabled={disabled || state === 'UPLOADING'} accept="image/png,image/jpeg" aria-label={t('skillAvatar.choose')} onChange={event => {
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')
}} />
<div {...getRootProps()}>
<Input {...getInputProps({ 'aria-label': t('skillAvatar.choose'), style: undefined, tabIndex: 0, disabled: disabled || state === 'UPLOADING' })} />
</div>
{sourceUrl && state !== 'READY' && <div className="space-y-2">
<div className="h-40 w-40 overflow-hidden rounded-lg border"><img src={sourceUrl} alt={t('skillAvatar.cropPreview')} className="h-full w-full object-cover" style={{ transform: `scale(${zoom})` }} /></div>
<label className="block text-sm">{t('skillAvatar.zoom')} <input type="range" min="1" max="3" step="0.1" value={zoom} disabled={disabled || state === 'UPLOADING'} onChange={event => setZoom(Number(event.target.value))} /></label>
<Button type="button" disabled={disabled || state === 'UPLOADING'} onClick={() => void upload()}>{t(state === 'ERROR' ? 'skillAvatar.uploadRetry' : 'skillAvatar.cropUpload')}</Button>
<ReactCrop crop={crop} aspect={1} keepSelection minWidth={16} minHeight={16}
disabled={disabled || state === 'UPLOADING'}
onChange={(_, percentCrop) => 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'),
}}>
<img ref={image} src={sourceUrl} alt={t('skillAvatar.cropPreview')} className="max-h-80 max-w-full"
onLoad={event => {
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) }} />
</ReactCrop>
<p className="text-sm text-muted-foreground">{t('skillAvatar.cropHelp')}</p>
<Button type="button" disabled={disabled || state === 'UPLOADING' || !crop?.width || !crop.height} onClick={() => void upload()}>{t(state === 'ERROR' ? 'skillAvatar.uploadRetry' : 'skillAvatar.cropUpload')}</Button>
</div>}
<div className="flex flex-wrap gap-2">
{state !== 'KEEP' && <Button type="button" variant="outline" disabled={disabled} onClick={() => reset()}>{t('skillAvatar.discardChange')}</Button>}

View file

@ -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",

View file

@ -2622,7 +2622,18 @@
"candidatePreview": "Новый аватар",
"choose": "Выбрать изображение",
"cropPreview": "Предпросмотр обрезки",
"zoom": "Масштаб",
"cropArea": "Область обрезки",
"cropHelp": "Перетащите выделение или его края. Выберите область и перемещайте её клавишами со стрелками.",
"cropHandles": {
"nw": "Изменить верхний левый угол",
"n": "Изменить верхний край",
"ne": "Изменить верхний правый угол",
"e": "Изменить правый край",
"se": "Изменить нижний правый угол",
"s": "Изменить нижний край",
"sw": "Изменить нижний левый угол",
"w": "Изменить левый край"
},
"submit": "Отправить аватар",
"remove": "Удалить аватар",
"appeal": "Запросить пересмотр",

View file

@ -2619,7 +2619,18 @@
"candidatePreview": "待审核头像",
"choose": "选择头像图片",
"cropPreview": "裁剪预览",
"zoom": "放大",
"cropArea": "裁剪区域",
"cropHelp": "拖动选区或边角调整;聚焦选区后可用方向键移动。",
"cropHandles": {
"nw": "调整左上角",
"n": "调整上边",
"ne": "调整右上角",
"e": "调整右边",
"se": "调整右下角",
"s": "调整下边",
"sw": "调整左下角",
"w": "调整左边"
},
"submit": "提交头像",
"remove": "删除当前头像",
"appeal": "申请复核",