@@ -0,0 +1,43 @@
import Head from 'next/head' ;
import { useEffect , useRef , useState , type PointerEvent } from 'react' ;
import { MousePointer2 , Type , Square , Circle , Plus , Minus , Undo2 , Redo2 , Download , Layers , ImagePlus , Trash2 , Copy , Eye , EyeOff , Lock , Unlock , Scan , ChevronRight , ArrowUpRight , PenTool , X } from 'lucide-react' ;
import { newElementId } from '../../lib/native-layout' ;
import styles from '../../styles/graphic.module.css' ;
import { parseGraphicDocument , type GraphicItem as Item , type GraphicDocument as Doc } from '../../lib/graphic-document' ;
const blank :Doc = { name : '未命名设计' , background : '#ffffff' , items : [ ] } ;
const storage = 'studio.graphic.v1' ;
export default function Graphic ( ) {
const [ doc , setDoc ] = useState < Doc > ( blank ) , [ selected , setSelected ] = useState ( '' ) , [ past , setPast ] = useState < Doc [ ] > ( [ ] ) , [ future , setFuture ] = useState < Doc [ ] > ( [ ] ) , [ zoom , setZoom ] = useState ( . 65 ) , [ status , setStatus ] = useState ( '本机草稿' ) , [ tab , setTab ] = useState ( '图层' ) , [ user , setUser ] = useState < { email :string } | null > ( null ) , [ auth , setAuth ] = useState ( false ) , [ mode , setMode ] = useState ( 'login' ) , [ message , setMessage ] = useState ( '' ) , [ busy , setBusy ] = useState ( false ) ;
const dialog = useRef < HTMLDivElement > ( null ) ;
useEffect ( ( ) = > { if ( ! auth ) return ; const old = document . activeElement as HTMLElement ; const box = dialog . current ; box ? . querySelector < HTMLElement > ( 'button,input' ) ? . focus ( ) ; function key ( e :KeyboardEvent ) { if ( e . key === 'Escape' ) { setAuth ( false ) ; return } if ( e . key !== 'Tab' || ! box ) return ; const nodes = Array . from ( box . querySelectorAll < HTMLElement > ( 'button:not(:disabled),input,a[href]' ) ) ; const first = nodes [ 0 ] , last = nodes [ nodes . length - 1 ] ; if ( e . shiftKey && document . activeElement === first ) { e . preventDefault ( ) ; last ? . focus ( ) } else if ( ! e . shiftKey && document . activeElement === last ) { e . preventDefault ( ) ; first ? . focus ( ) } } document . addEventListener ( 'keydown' , key ) ; return ( ) = > { document . removeEventListener ( 'keydown' , key ) ; old ? . focus ( ) } } , [ auth ] ) ;
const restore = useRef < HTMLInputElement > ( null ) ;
const board = useRef < HTMLDivElement > ( null ) , upload = useRef < HTMLInputElement > ( null ) , viewport = useRef < HTMLDivElement > ( null ) , ready = useRef ( false ) , dirty = useRef ( false ) ;
const item = doc . items . find ( i = > i . id === selected ) ;
useEffect ( ( ) = > { try { const raw = localStorage . getItem ( storage ) ; if ( raw ) { setDoc ( parseGraphicDocument ( raw ) ) ; } } catch { setStatus ( '无法读取草稿,请导出备份' ) } ready . current = true ; void refresh ( ) ; } , [ ] ) ;
useEffect ( ( ) = > { if ( ! ready . current ) return ; try { localStorage . setItem ( storage , JSON . stringify ( doc ) ) ; setStatus ( '已保存到此浏览器' ) ; dirty . current = false ; } catch { setStatus ( '本机空间不足,请导出备份' ) ; dirty . current = true ; } } , [ doc ] ) ;
useEffect ( ( ) = > { const warn = ( e :BeforeUnloadEvent ) = > { if ( dirty . current ) { e . preventDefault ( ) ; e . returnValue = '' } } ; window . addEventListener ( 'beforeunload' , warn ) ; return ( ) = > window . removeEventListener ( 'beforeunload' , warn ) } , [ ] ) ;
async function refresh ( ) { try { const r = await fetch ( '/api/account/session' ) ; if ( r . ok ) setUser ( ( await r . json ( ) ) . user ) ; } catch { /* Guest editing remains available. */ } }
function change ( next :Doc ) { setPast ( p = > [ . . . p . slice ( - 49 ) , doc ] ) ; setFuture ( [ ] ) ; setDoc ( next ) ; dirty . current = true ; }
function patch ( p :Partial < Item > ) { if ( item && ! item . locked ) change ( { . . . doc , items :doc.items.map ( i = > i . id === selected ? { . . . i , . . . p } : i ) } ) }
function add ( kind :Item [ 'kind' ] , src? :string ) { if ( doc . items . length >= 500 ) { setStatus ( '单页最多 500 个图层' ) ; return ; } const id = newElementId ( ) ; const next :Item = { id , kind , name :kind === 'text' ? '文字' : kind === 'image' ? '图片' : kind === 'rect' ? '矩形' : '椭圆' , text : '让灵感,有形。' , x :90 + doc . items . length * 12 % 260 , y :100 + doc . items . length * 12 % 400 , w :kind === 'text' ? 480 :260 , h :kind === 'text' ? 90 :220 , fill :kind === 'text' ? '#282832' : '#b8b3ff' , size :52 , hidden :false , locked :false , src } ; change ( { . . . doc , items : [ . . . doc . items , next ] } ) ; setSelected ( id ) ; setTab ( '图层' ) ; }
function undo ( ) { if ( ! past . length ) return ; setFuture ( f = > [ doc , . . . f ] ) ; setDoc ( past [ past . length - 1 ] ) ; setPast ( p = > p . slice ( 0 , - 1 ) ) ; }
function redo ( ) { if ( ! future . length ) return ; setPast ( p = > [ . . . p , doc ] ) ; setDoc ( future [ 0 ] ) ; setFuture ( f = > f . slice ( 1 ) ) ; }
function remove ( ) { if ( item && ! item . locked ) { change ( { . . . doc , items :doc.items.filter ( i = > i . id !== selected ) } ) ; setSelected ( '' ) } }
function duplicate ( ) { if ( item && doc . items . length < 500 ) { const id = newElementId ( ) ; change ( { ...doc , items : [ ...doc.items , { ...item , id , x : item.x + 24 , y : item.y + 24 , name : item.name + ' 副本' , locked : false } ] } ) ; setSelected ( id ) }}
function fit ( ) { if ( viewport.current ) setZoom ( Math.max ( .15 , Math.min ( 1 , ( viewport.current.clientWidth - 100 ) / 800 , ( viewport.current.clientHeight - 100 ) / 1000 ) ) ) }
useEffect ( ( ) = > { fit ( ) ; } , [ ] ) ;
useEffect ( ( ) = > { function key ( e :KeyboardEvent ) { if ( ( e . target as HTMLElement ) . closest ( 'input,textarea,select,button' ) ) return ; if ( ( e . metaKey || e . ctrlKey ) && e . key . toLowerCase ( ) === 'z' ) { e . preventDefault ( ) ; e . shiftKey ? redo ( ) : undo ( ) } else if ( ( e . metaKey || e . ctrlKey ) && e . key . toLowerCase ( ) === 'd' ) { e . preventDefault ( ) ; duplicate ( ) } else if ( e . key === 'Delete' || e . key === 'Backspace' ) { e . preventDefault ( ) ; remove ( ) } else if ( e . key === 'Escape' ) setSelected ( '' ) ; else if ( item && ! item . locked && e . key . startsWith ( 'Arrow' ) ) { e . preventDefault ( ) ; const step = e.shiftKey?10 :1 ; patch ( { x :item.x + ( e . key === 'ArrowRight' ? step :e.key === 'ArrowLeft' ? - step :0 ) , y :item.y + ( e . key === 'ArrowDown' ? step :e.key === 'ArrowUp' ? - step :0 ) } ) } } window . addEventListener ( 'keydown' , key ) ; return ( ) = > window . removeEventListener ( 'keydown' , key ) } ) ;
function drag ( e :PointerEvent < HTMLDivElement > , i :Item ) { e . stopPropagation ( ) ; setSelected ( i . id ) ; if ( i . locked || e . button !== 0 ) return ; e . preventDefault ( ) ; e . currentTarget . setPointerCapture ( e . pointerId ) ; const x = e . clientX , y = e . clientY , start = doc ; let next = start ; const target = e . currentTarget ; const move = ( p :globalThis.PointerEvent ) = > { next = { . . . start , items :start.items.map ( n = > n . id === i . id ? { . . . n , x :Math.max ( - 4000 , Math . min ( 4000 , i . x + ( p . clientX - x ) / zoom ) ) , y :Math.max ( - 4000 , Math . min ( 4000 , i . y + ( p . clientY - y ) / zoom ) ) } : n ) } ; setDoc ( next ) } ; const end = ( ) = > { target . removeEventListener ( 'pointermove' , move ) ; target . removeEventListener ( 'pointerup' , end ) ; target . removeEventListener ( 'pointercancel' , end ) ; if ( next !== start ) { setPast ( p = > [ . . . p . slice ( - 49 ) , start ] ) ; setFuture ( [ ] ) } } ; target . addEventListener ( 'pointermove' , move ) ; target . addEventListener ( 'pointerup' , end ) ; target . addEventListener ( 'pointercancel' , end ) ; }
function download ( content :string , type : string , extension :string ) { const url = URL . createObjectURL ( new Blob ( [ content ] , { type } ) ) ; const a = document . createElement ( 'a' ) ; a . href = url ; a . download = doc . name + '.' + extension ; a . click ( ) ; setTimeout ( ( ) = > URL . revokeObjectURL ( url ) , 1000 ) }
function exportSVG ( ) { const esc = ( s :string ) = > s . replace ( /[&<>"']/g , c = > ( { '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' } [ c ] ! ) ) ; const svg = doc . items . filter ( i = > ! i . hidden ) . map ( i = > i . kind === 'text' ? ` <text x=" ${ i . x } " y=" ${ i . y + i . size } " font-family="sans-serif" font-size=" ${ i . size } " fill=" ${ esc ( i . fill ) } "> ${ i . text . split ( '\n' ) . map ( ( line , n ) = > ` <tspan x=" ${ i . x } " dy=" ${ n ? i . size * 1.3 :0 } "> ${ esc ( line ) } </tspan> ` ) . join ( '' ) } </text> ` : i . kind === 'image' ? ` <image x=" ${ i . x } " y=" ${ i . y } " width=" ${ i . w } " height=" ${ i . h } " href=" ${ esc ( i . src || '' ) } " preserveAspectRatio="xMidYMid slice"/> ` : i . kind === 'ellipse' ? ` <ellipse cx=" ${ i . x + i . w / 2 } " cy=" ${ i . y + i . h / 2 } " rx=" ${ i . w / 2 } " ry=" ${ i . h / 2 } " fill=" ${ esc ( i . fill ) } "/> ` : ` <rect x=" ${ i . x } " y=" ${ i . y } " width=" ${ i . w } " height=" ${ i . h } " fill=" ${ esc ( i . fill ) } "/> ` ) . join ( '' ) ; download ( ` <svg xmlns="http://www.w3.org/2000/svg" width="800" height="1000" viewBox="0 0 800 1000"><rect width="800" height="1000" fill=" ${ esc ( doc . background ) } "/> ${ svg } </svg> ` , 'image/svg+xml' , 'svg' ) ; }
async function account ( e :React.FormEvent < HTMLFormElement > ) { e . preventDefault ( ) ; setBusy ( true ) ; setMessage ( '' ) ; const data = new FormData ( e . currentTarget ) ; try { const r = await fetch ( '/api/account/' + mode , { method : 'POST' , headers : { 'Content-Type' : 'application/json' } , body :JSON.stringify ( { email :data.get ( 'email' ) , password :data.get ( 'password' ) } ) } ) ; const d = await r . json ( ) ; setMessage ( d . error || d . message || '登录成功' ) ; if ( r . ok ) { await refresh ( ) ; if ( mode === 'login' ) setAuth ( false ) ; } } catch { setMessage ( '账户服务暂时无法连接' ) } finally { setBusy ( false ) } }
return < div className = { styles . app } > < Head > < title > { doc . name + " · Studio Graphic" } < / title > < meta name = "description" content = "在 Studio 中自由编排文字、图形与图片。" / > < / Head >
< header className = { styles . top } > < a href = "/studio/" className = { styles . brand } > < span > ✳ < / span > studio < span className = { styles . badge } > GRAPHIC < / span > < / a > < ChevronRight size = { 14 } / > < input aria-label = "设计名称" value = { doc . name } maxLength = { 100 } onChange = { e = > change ( { . . . doc , name :e.target.value } ) } / > < span className = { styles . saved } role = "status" > { status } < / span > < button title = "撤销" aria-label = "撤销" disabled = { ! past . length } onClick = { undo } > < Undo2 size = { 17 } / > < / button > < button title = "重做" aria-label = "重做" disabled = { ! future . length } onClick = { redo } > < Redo2 size = { 17 } / > < / button > < button onClick = { ( ) = > setAuth ( true ) } className = { styles . account } > { user ? user . email : '邮箱登录' } < / button > < button className = { styles . primary } onClick = { exportSVG } > < Download size = { 15 } / > 导 出 SVG < / button > < / header >
< div className = { styles . body } > < aside className = { styles . left } > < div className = { styles . tabs } > { [ '图层' , '素材' ] . map ( t = > < button key = { t } aria-pressed = { tab === t } onClick = { ( ) = > setTab ( t ) } > { t } < / button > ) } < / div > < div className = { styles . pageTitle } > < span > 页 面 01 < / span > < span > 800 × 1000 < / span > < / div > { tab === '图层' ? < > < div className = { styles . layers } > { [ . . . doc . items ] . reverse ( ) . map ( i = > < div className = { styles . layer } data-selected = { i . id === selected } key = { i . id } > < button onClick = { ( ) = > setSelected ( i . id ) } > { i . kind === 'text' ? < Type size = { 14 } / > : < Square size = { 14 } / > } < span > { i . name } < / span > < / button > < button aria-label = { i . hidden ? '显示图层' : '隐藏图层' } onClick = { ( ) = > change ( { . . . doc , items :doc.items.map ( n = > n . id === i . id ? { . . . n , hidden : ! n . hidden } : n ) } ) } > { i . hidden ? < EyeOff size = { 13 } / > : < Eye size = { 13 } / > } < / button > < button aria-label = { i . locked ? '解锁图层' : '锁定图层' } onClick = { ( ) = > change ( { . . . doc , items :doc.items.map ( n = > n . id === i . id ? { . . . n , locked : ! n . locked } : n ) } ) } > { i . locked ? < Lock size = { 13 } / > : < Unlock size = { 13 } / > } < / button > < / div > ) } { ! doc . items . length && < div className = { styles . emptyLayers } > < Layers size = { 23 } / > < p > 好 设 计 , 从 第 一 笔 开 始 < / p > < small > 从 下 方 工 具 栏 添 加 文 字 与 图 形 < / small > < / div > } < / div > < button className = { styles . addLayer } onClick = { ( ) = > add ( 'text' ) } > < Plus size = { 15 } / > 添 加 图 层 < / button > < / > : < div className = { styles . assets } > < h3 > 基 础 元 素 < / h3 > < button onClick = { ( ) = > add ( 'rect' ) } > < Square / > 矩 形 < / button > < button onClick = { ( ) = > add ( 'ellipse' ) } > < Circle / > 椭 圆 < / button > < button onClick = { ( ) = > add ( 'text' ) } > < Type / > 文 字 < / button > < button onClick = { ( ) = > upload . current ? . click ( ) } > < ImagePlus / > 上 传 图 片 < / button > < p > 你 的 画 布 , 由 你 定 义 。 支 持 PNG 、 JPEG 、 WebP 。 < / p > < / div > } < div className = { styles . leftFooter } > < a href = "/studio/infinite_canvas" > Infinite Canvas < ArrowUpRight size = { 14 } / > < / a > < small > ⌘ Z 撤 销 · 方 向 键 微 调 · Delete 删 除 < / small > < / div > < / aside >
< main ref = { viewport } className = { styles . viewport } onPointerDown = { ( ) = > setSelected ( '' ) } > < div className = { styles . canvasLabel } > 画 板 01 < span > 800 × 1000 < / span > < / div > < div className = { styles . boardWrap } style = { { width :800 * zoom , height :1000 * zoom } } > < div ref = { board } className = { styles . board } style = { { background :doc.background , transform : ` scale( ${ zoom } ) ` } } > { doc . items . filter ( i = > ! i . hidden ) . map ( i = > < div key = { i . id } tabIndex = { 0 } role = "button" aria-label = { i . name } aria-pressed = { selected === i . id } onFocus = { ( ) = > setSelected ( i . id ) } onPointerDown = { e = > drag ( e , i ) } className = { styles . element } data-selected = { selected === i . id } style = { { left :i.x , top :i.y , width :i.w , height :i.h , borderRadius :i.kind === 'ellipse' ? '50%' : 0 , background :i.kind === 'rect' || i . kind === 'ellipse' ? i.fill :undefined , color :i.fill , fontSize :i.size , whiteSpace : 'pre-wrap' } } > { i . kind === 'text' ? i.text :i.kind === 'image' ? < img src = { i . src } alt = { i . name } draggable = { false } / > : null } { selected === i . id && < > < i className = { styles . corner } / > < span className = { styles . measure } > { Math . round ( i . w ) } × { Math . round ( i . h ) } < / span > < / > } < / div > ) } { ! doc . items . length && < div className = { styles . blankHint } > < PenTool size = { 34 } strokeWidth = { 1 } / > < h1 > 留 白 , 是 创 作 的 起 点 。 < / h1 > < p > 添 加 文 字 、 图 形 , 或 放 入 你 的 第 一 张 图 片 。 < / p > < button onPointerDown = { e = > e . stopPropagation ( ) } onClick = { ( ) = > add ( 'text' ) } > 开 始 设 计 < ArrowUpRight size = { 16 } / > < / button > < / div > } < / div > < / div > < div className = { styles . tools } onPointerDown = { e = > e . stopPropagation ( ) } > < button title = "选择" aria-label = "选择工具" onClick = { ( ) = > setSelected ( '' ) } > < MousePointer2 size = { 20 } / > < / button > < button title = "文字" aria-label = "添加文字" onClick = { ( ) = > add ( 'text' ) } > < Type size = { 20 } / > < / button > < button title = "矩形" aria-label = "添加矩形" onClick = { ( ) = > add ( 'rect' ) } > < Square size = { 19 } / > < / button > < button title = "椭圆" aria-label = "添加椭圆" onClick = { ( ) = > add ( 'ellipse' ) } > < Circle size = { 19 } / > < / button > < button title = "图片" aria-label = "上传图片" onClick = { ( ) = > upload . current ? . click ( ) } > < ImagePlus size = { 19 } / > < / button > < span / > < button aria-label = "缩小" onClick = { ( ) = > setZoom ( z = > Math . max ( . 15 , z - . 1 ) ) } > < Minus size = { 16 } / > < / button > < output > { Math . round ( zoom * 100 ) } % < / output > < button aria-label = "放大" onClick = { ( ) = > setZoom ( z = > Math . min ( 2 , z + . 1 ) ) } > < Plus size = { 16 } / > < / button > < button aria-label = "适应画布" onClick = { fit } > < Scan size = { 18 } / > < / button > < / div > < / main >
< aside className = { styles . right } > < div className = { styles . inspectorTitle } > 设 计 属 性 < span > Design < / span > < / div > { item ? < div className = { styles . props } > < label > 图 层 名 称 < input maxLength = { 200 } value = { item . name } disabled = { item . locked } onChange = { e = > patch ( { name :e.target.value } ) } / > < / label > < h3 > 布 局 < / h3 > < div className = { styles . fields } > { ( [ 'x' , 'y' , 'w' , 'h' ] as const ) . map ( k = > < label key = { k } > { ( { x : 'X' , y : 'Y' , w : '宽' , h : '高' } ) [ k ] } < input type = "number" value = { Math . round ( item [ k ] ) } disabled = { item . locked } onChange = { e = > { const value = Number ( e . target . value ) ; if ( Number . isFinite ( value ) ) patch ( { [ k ] : k === 'w' || k === 'h' ? Math . max ( 1 , Math . min ( 4000 , value ) ) : Math . max ( - 4000 , Math . min ( 4000 , value ) ) } ) } } / > < / label > ) } < / div > { item . kind === 'text' && < > < h3 > 文 字 < / h3 > < textarea aria-label = "文字内容" value = { item . text } maxLength = { 4000 } disabled = { item . locked } onChange = { e = > patch ( { text :e.target.value } ) } / > < label > 字 号 < input type = "number" min = { 8 } max = { 240 } value = { item . size } disabled = { item . locked } onChange = { e = > patch ( { size :Math.max ( 8 , Math . min ( 240 , Number ( e . target . value ) || 8 ) ) } ) } / > < / label > < / > } < h3 > 填 色 < / h3 > < label className = { styles . color } > < input type = "color" value = { item . fill } disabled = { item . locked } onChange = { e = > patch ( { fill :e.target.value } ) } / > { item . fill . toUpperCase ( ) } < / label > < div className = { styles . actions } > < button onClick = { duplicate } > < Copy size = { 15 } / > 复 制 < / button > < button disabled = { item . locked } onClick = { remove } > < Trash2 size = { 15 } / > 删 除 < / button > < / div > < button disabled = { item . locked } onClick = { ( ) = > { const n = doc . items . findIndex ( i = > i . id === item . id ) ; if ( n > 0 ) { const items = [ . . . doc . items ] ; [ items [ n - 1 ] , items [ n ] ] = [ items [ n ] , items [ n - 1 ] ] ; change ( { . . . doc , items } ) } } } > 下 移 一 层 < / button > < / div > : < div className = { styles . props } > < h3 > 画 板 < / h3 > < p > 自 由 画 布 · 竖 版 < / p > < div className = { styles . dimensions } > W < b > 800 < / b > H < b > 1000 < / b > < / div > < h3 > 背 景 颜 色 < / h3 > < label className = { styles . color } > < input type = "color" value = { doc . background } onChange = { e = > change ( { . . . doc , background :e.target.value } ) } / > { doc . background . toUpperCase ( ) } < / label > < div className = { styles . note } > < MousePointer2 size = { 20 } / > < p > 选 中 一 个 图 层 , < br / > 调 整 属 于 它 的 每 个 细 节 。 < / p > < / div > < / div > } < div className = { styles . rightFooter } > < button onClick = { ( ) = > download ( JSON . stringify ( doc , null , 2 ) , 'application/json' , 'json' ) } > < Download size = { 15 } / > 备 份 设 计 JSON < / button > < button onClick = { ( ) = > restore . current ? . click ( ) } > 导 入 设 计 JSON < / button > < p > 草 稿 仅 保 存 在 此 浏 览 器 。 < br / > 邮 箱 登 录 共 用 全 站 账 户 。 < / p > < / div > < / aside > < / div >
< input ref = { restore } hidden type = "file" accept = "application/json,.json" onChange = { async e = > { const f = e . target . files ? . [ 0 ] ; e . target . value = '' ; if ( ! f ) return ; try { if ( f . size > 5 * 1024 * 1024 ) throw Error ( '文件不得超过 5MB' ) ; const restored = parseGraphicDocument ( await f . text ( ) ) ; change ( restored ) ; setSelected ( '' ) ; } catch ( error ) { setStatus ( error instanceof Error ? error . message : '导入失败' ) } } } / >
< input ref = { upload } hidden type = "file" accept = "image/png,image/jpeg,image/webp" onChange = { e = > { const f = e . target . files ? . [ 0 ] ; if ( ! f ) return ; if ( f . size > 3 * 1024 * 1024 || ! [ 'image/png' , 'image/jpeg' , 'image/webp' ] . includes ( f . type ) ) { setStatus ( '请使用 3MB 以内的 PNG / JPEG / WebP' ) ; return } const reader = new FileReader ( ) ; reader . onload = ( ) = > add ( 'image' , String ( reader . result ) ) ; reader . readAsDataURL ( f ) ; e . target . value = '' } } / >
{ auth && < div className = { styles . overlay } > < div ref = { dialog } className = { styles . modal } role = "dialog" aria-modal = "true" aria-label = "邮箱账户" > < button className = { styles . close } aria-label = "关闭" onClick = { ( ) = > setAuth ( false ) } > < X size = { 20 } / > < / button > < span className = { styles . modalIcon } > ✳ < / span > < h2 > { user ? '你的 Studio 账户' : '把灵感带进 Studio' } < / h2 > { user ? < > < p > { user . email } < / p > < button onClick = { async ( ) = > { try { const r = await fetch ( '/api/account/logout' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' } , body : '{}' } ) ; if ( ! r . ok ) throw Error ( ) ; setUser ( null ) ; setAuth ( false ) } catch { setMessage ( '退出失败,请重试' ) } } } > 退 出 登 录 < / button > < / > : < > < p > 使 用 知 识 站 的 同 一 个 邮 箱 账 户 < / p > < div className = { styles . tabs } > { [ [ 'login' , '登录' ] , [ 'register' , '注册' ] , [ 'resend' , '重发邮件' ] ] . map ( ( [ v , t ] ) = > < button key = { v } aria-pressed = { mode === v } onClick = { ( ) = > setMode ( v ) } > { t } < / button > ) } < / div > < form onSubmit = { account } > < label > 邮 箱 < input autoFocus required type = "email" name = "email" autoComplete = "email" maxLength = { 254 } / > < / label > < label > 密 码 < input required type = "password" name = "password" minLength = { 12 } maxLength = { 256 } autoComplete = { mode === 'register' ? 'new-password' : 'current-password' } / > < / label > < small > 密 码 至 少 12 字 符 ; 新 账 户 需 通 过 邮 件 验 证 。 < / small > < button className = { styles . primary } disabled = { busy } > { busy ? '处理中…' : mode === 'login' ? '登录 Studio' : mode === 'register' ? '创建账户' : '重发验证邮件' } < / button > < / form > < / > } < p role = "status" > { message } < / p > < / div > < / div > }
< / div >
}