@ -1,234 +1,305 @@
<!-- lib/pages/print/PrintProgress.svelte -->
< script >
const currentStatus = $derived . by (() => {
let { store , onBack = () => {} } = $props ();
const currentStatus = $derived . by (() => {
if ( ! store . job ) return 'idle' ;
const f = store . job . files . find ( f => f . status === 'printing' );
if ( f ) return 'printing' ;
const q = store . job . files . find ( f => f . status === 'queued' );
return q ? 'queued' : store . job . phase ;
});
});
let { store , onBack = () => {} } = $props () ;
const OFFICE_EXT = [ 'DOC' , 'DOCX' , 'ODT' , 'XLS' , 'XLSX' , 'ODS' , 'PPT' , 'PPTX' , 'ODP' ];
const OFFICE_EXT = [ 'DOC' , 'DOCX' , 'ODT' , 'XLS' , 'XLSX' , 'ODS' , 'PPT' , 'PPTX' , 'ODP' ] ;
const STATUS_LABEL = {
queued : 'В очереди' ,
printing : 'Отправка...' ,
sent : 'Отправлен' ,
const STATUS_LABEL = {
queued : 'В очереди' ,
printing : 'Отправка...' ,
sent : 'Отправлен' ,
awaiting_clear_output : 'Уберите распечатанные листы' ,
awaiting_flip : 'Переложите бумагу' ,
awaiting_pickup : 'Заберите документ ',
done : 'Готов' ,
error : 'Ошибка' ,
cancelled : 'Отменено'
};
awaiting_flip : 'Переложите бумагу' ,
awaiting_pickup : 'Печать завершается... ' ,
done : 'Готов' ,
error : 'Ошибка' ,
cancelled : 'Отменено'
};
function badge ( name ) {
function badge ( name ) {
const e = ( name . split ( '.' ). pop () || '' ). toUpperCase ();
return OFFICE_EXT . includes ( e ) ? e : null ;
}
}
$effect (() => {
$effect (() => {
if ( ! store . jobActive ) return ;
const int = setInterval (() => store . refreshJob (), 1000 );
return () => clearInterval ( int );
});
});
// Проверка, можно ли отменить (есть ли файлы в очереди или печатающиеся)
const canCancel = $derived ( store . job ? . files . some ( f => f . status === 'queued' || f . status === 'printing' ));
const canCancel = $derived (
store . job ? . files . some ( f => f . status === 'queued' || f . status === 'printing' )
);
const HOLD_MS = 1500 ;
let hold = $state ( 0 );
let raf = 0 ;
let t0 = 0 ;
const HOLD_MS = 1500 ;
let hold = $state ( 0 );
let raf = 0 ;
let t0 = 0 ;
function startHold ( e ) {
function startHold ( e ) {
if ( ! store . jobActive || ! canCancel ) return ;
e . preventDefault ();
e . currentTarget . setPointerCapture ? .( e . pointerId );
t0 = performance . now ();
const step = ( now ) => {
hold = Math . min ( 1 , ( now - t0 ) / HOLD_MS );
if ( hold >= 1 ) {
raf = 0 ;
hold = 0 ;
store . cancelJob ();
return ;
}
raf = requestAnimationFrame ( step );
hold = Math . min ( 1 , ( now - t0 ) / HOLD_MS );
if ( hold >= 1 ) { raf = 0 ; hold = 0 ; store . cancelJob (); return ; }
raf = requestAnimationFrame ( step ) ;
};
raf = requestAnimationFrame ( step );
}
}
function endHold () {
function endHold () {
if ( raf ) cancelAnimationFrame ( raf );
raf = 0 ;
hold = 0 ;
}
}
function finish () {
function finish () {
store . resetJob ();
onBack ();
}
}
</ script >
< div class = "page-container" >
< header class = "header" >
< button class = "back-btn" disabled = { store . jobActive } onclick= { finish } > ←</ button >
< h1 > Печать</ h1 >
</ header >
< main class = "list" >
{ #if store . job }
{ #each store . job . files as f , i ( i + '-' + f . name )}
{ @const b = badge ( f . name )}
{ @const isLastDone = f . status === 'done' && ! store . job . files . slice ( i + 1 ). some ( f => f . status === 'printing' || f . status === 'queued' )}
{ @const hasNextQueued = store . job . files . slice ( i + 1 ). some ( f => f . status === 'queued' )}
< section class = "file-card" class:current = { f . status === 'awaiting_flip' || f . status === 'awaiting_clear_output' || f . status === 'printing' || isLastDone } >
< div class = "file-row" >
{ #if b } < span class = "type-badge" > { b } </ span > { /if }
< span class = "file-name" title = { f . name } > { f . name } </span >
< span class = "file-status st- { f . status } " > { STATUS_LABEL [ f . status ] ?? f . status } </ span >
</ div >
{ #if f . status === 'awaiting_clear_output' }
< ol class = "pickup-hint clear-hint" >
< li > Уберите распечатанные листы из выходного лотка</ li >
< li > Нажмите кнопку ниже для запуска двусторонней печати</ li >
</ ol >
{ :else if f . status === 'awaiting_flip' }
< ol class = "pickup-hint flip-hint" >
< li > Н е переворачивая, положите остальные листы в слот ручной подачи</ li >
</ ol >
{ :else if isLastDone && hasNextQueued && ! store . autoContinue }
< ol class = "pickup-hint" >
< li > Возьмите готовый документ</ li >
< li > Нажмите далее</ li >
</ ol >
{ /if }
{ #if f . error }
< p class = "file-error" > { f . error } </ p >
{ /if }
</ section >
{ /each }
{ /if }
</ main >
< footer class = "actions" >
< label class = "auto-row" >
< input
type = "checkbox"
checked = { store . autoContinue }
onchange= {( e ) => store . setAutoContinue ( e . target . checked )}
/>
< span > Автоматически продолжать печать</ span >
</ label >
{ #if store . autoContinue }
< p class = "auto-warning" > ⚠️ Отправленные файлы нельзя будет отменить</ p >
{ /if }
< header class = "header" >
< button class = "back-btn" disabled = { store . jobActive } onclick= { finish } > ←</ button >
< h1 > Печать</ h1 >
</ header >
{ #if store . jobActive }
{ #if store . job ? . phase === 'awaiting_clear_output' }
< button class = "next-btn next-btn-clear" onclick = { store . advanceJob } >
Убрал листы — печатать
</ button >
{ :else if store . job ? . phase === 'awaiting_flip' }
< button clas s ="next-btn next-btn-flip" onclick = { store . advanceJob } >
Переложил — печатать обратную сторону
</ button >
{ :else if store . job ? . phase === 'awaiting_pickup' }
< button class = "next-btn" onclick = { store . advanceJob } > Забрал — далее </ button >
{ : else }
< button class = "next-btn next-btn-disabled" disabled >
{ currentStatus === 'printing' ? 'Отправка...' : 'Ожидание...' }
</ button >
{ /if }
< main class = "list" >
{ #if store . job }
{ #each store . job . files as f , i ( i + '-' + f . name ) }
{ @const b = badge ( f . name )}
{ @const isActive = f . status === 'awaiting_flip'
|| f . status === 'awaiting_clear_output'
|| f . statu s === 'awaiting_pickup'
|| f . status === 'printing' }
< button
class = "cancel-btn"
disabled = { ! canCancel }
onpointerdown= { canCancel ? startHold : null }
onpointerup = { endHold }
onpointercancel= { endHold }
onpointerleave = { endHold }
oncontextmenu= {( e ) => e . preventDefault ()}
>
< span class = "cancel-fill" style:width = " { hold * 100 } %" ></ span >
< span class = "cancel-label" > { canCancel ? 'Отмена' : 'Отмена недоступна' } </ span >
</ button >
{ #if canCancel }
< p class = "hold-hint" > Удерживайте для отмены печати</ p >
{ /if }
{ : else }
< button class = "home-btn" onclick = { finish } > Н а главную </ button >
{ /if }
</ footer >
< section class = "file-card" class:current = { isActive } >
< div class = "file-row" >
{ #if b } < span class = "type-badge" > { b } </ span > { /if }
< span class = "file-name" title = { f . name } > { f . name } </span >
< span class = "file-status st- { f . status } " >
{ STATUS_LABEL [ f . status ] ?? f . status }
</ span >
</ div >
{ #if f . status === 'awaiting_pickup' }
< ol class = "pickup-hint" >
< li > Дождитесь завершения печати</ li >
< li > Нажмите кнопку ниже для подтверждения</ li >
</ ol >
{ :else if f . status === 'awaiting_clear_output' }
< ol class = "pickup-hint clear-hint" >
< li > Уберите распечатанные листы из выходного лотка</ li >
< li > Нажмите кнопку ниже для продолжения</ li >
</ ol >
{ :else if f . status === 'awaiting_flip' }
< ol class = "pickup-hint flip-hint" >
< li > Н е переворачивая, положите листы в слот ручной подачи</ li >
< li > Нажмите кнопку ниже для печати обратной стороны</ li >
</ ol >
{ /if }
{ #if f . error }
< p class = "file-error" > { f . error } </ p >
{ /if }
</ section >
{ /each }
{ /if }
</ main >
< footer class = "actions" >
< label class = "auto-row" >
< input
type = "checkbox"
checked = { store . autoContinue }
onchange= {( e ) => store . setAutoContinue ( e . target . checked )}
/>
< span > Автоматически продолжать печать</ span >
</ label >
{ #if store . autoContinue }
< p class = "auto-warning" > ⚠️ Отправленные файлы нельзя будет отменить</ p >
{ /if }
{ #if store . jobActive }
{ #if store . job ? . phase === 'awaiting_pickup' }
< button class = "next-btn" onclick = { store . advanceJob } >
Печать закончена — далее
</ button >
{ :else if store . job ? . phase === 'awaiting_clear_output' }
< button class = "next-btn next-btn-clear" onclick = { store . advanceJob } >
Убрал листы — продолжать
</ button >
{ :else if store . job ? . phase === 'awaiting_flip' }
< button class = "next-btn next-btn-flip" onclick = { store . advanceJob } >
Переложил — печатать обратную сторону
</ button >
{ : else }
< button class = "next-btn next-btn-disabled" disabled >
{ currentStatus === 'printing' ? 'Отправка...' : 'Ожидание...' }
</ button >
{ /if }
< button
class = "cancel-btn"
disabled = { ! canCancel }
onpointerdown= { canCancel ? startHold : null }
onpointerup = { endHold }
onpointercancel= { endHold }
onpointerleave = { endHold }
oncontextmenu= {( e ) => e . preventDefault ()}
>
< span class = "cancel-fill" style:width = " { hold * 100 } %" ></ span >
< span class = "cancel-label" > { canCancel ? 'Отмена' : 'Отмена недоступна' } </ span >
</ button >
{ #if canCancel }
< p class = "hold-hint" > Удерживайте для отмены печати</ p >
{ /if }
{ : else }
< button class = "home-btn" onclick = { finish } > Н а главную </ button >
{ /if }
</ footer >
</ div >
< style >
. st-awaiting_clear_output { color : #f59e0b ; }
. st-awaiting_flip { color : #f59e0b ; }
. st-sent { color : #8b93a1 ; }
. st-awaiting_pickup { color : #a5b4fc ; }
. clear-hint { color : #f59e0b ; }
. flip-hint { color : #f59e0b ; }
. next-btn-clear {
background : linear-gradient ( 135 deg , #f59e0b 0 % , #d97706 100 % );
box-shadow : 0 8 px 24 px -6 px rgba ( 245 , 158 , 11 , 0.4 );
}
. auto-warning {
margin : -4 px 0 8 px ;
text-align : center ;
color : #f59e0b ;
font-size : 13 px ;
font-weight : 600 ;
}
. st-awaiting_clear_output { color : #f59e0b ; }
. st-awaiting_flip { color : #f59e0b ; }
. st-awaiting_pickup { color : #a5b4fc ; }
. st-sent { color : #8b93a1 ; }
. st-done { color : #22c55e ; }
. st-queued { color : #8b93a1 ; }
. st-printing { color : #a5b4fc ; }
. st-error { color : #ef4444 ; }
. st-cancelled { color : #6b7280 ; }
. page-container { width : 100 % ; min-height : 100 dvh ; display : flex ; flex-direction : column ; background : radial-gradient ( 120 % 60 % at 50 % -10 % , rgba ( 99 , 102 , 241 , 0.18 ) 0 % , transparent 60 % ); font-family : system-ui , - apple-system , sans-serif ; color : #f5f7fa ; }
. header { padding : 24 px 20 px 8 px ; display : flex ; align-items : center ; position : relative ; }
. back-btn { background : none ; border : none ; color : #6366f1 ; font-size : 15 px ; font-weight : 600 ; cursor : pointer ; padding : 8 px 4 px ; margin-right : auto ; min-height : 44 px ; }
. back-btn : disabled { opacity : 0.3 ; cursor : not-allowed ; }
. header h1 { margin : 0 ; font-size : 28 px ; font-weight : 700 ; position : absolute ; left : 50 % ; transform : translateX ( -50 % ); white-space : nowrap ; }
. list { flex : 1 ; padding : 24 px 20 px 16 px ; display : flex ; flex-direction : column ; gap : 14 px ; overflow-y : auto ; }
. file-card { border-radius : 16 px ; background : rgba ( 255 , 255 , 255 , 0.04 ); border : 1 px solid rgba ( 255 , 255 , 255 , 0.08 ); padding : 14 px ; display : flex ; flex-direction : column ; gap : 8 px ; transition : border-color 0.2 s ease , background 0.2 s ease ; }
. file-card . current { border-color : rgba ( 99 , 102 , 241 , 0.45 ); background : rgba ( 99 , 102 , 241 , 0.07 ); }
. file-row { display : flex ; align-items : center ; gap : 12 px ; min-width : 0 ; }
. file-name { flex : 1 ; min-width : 0 ; font-size : 15 px ; font-weight : 500 ; overflow : hidden ; text-overflow : ellipsis ; white-space : nowrap ; }
. file-status { flex-shrink : 0 ; font-size : 14 px ; font-weight : 700 ; }
. st-done { color : #22c55e ; }
. st-queued { color : #8b93a1 ; }
. st-printing { color : #a5b4fc ; }
. st-error { color : #ef4444 ; }
. st-cancelled { color : #6b7280 ; }
. type-badge { flex-shrink : 0 ; padding : 3 px 7 px ; border-radius : 6 px ; background : rgba ( 99 , 102 , 241 , 0.2 ); border : 1 px solid rgba ( 99 , 102 , 241 , 0.35 ); color : #a5b4fc ; font-size : 10 px ; font-weight : 700 ; letter-spacing : 0.04 em ; line-height : 1.2 ; user-select : none ; }
. pickup-hint { margin : 0 ; padding-left : 22 px ; color : #eab308 ; font-size : 14 px ; line-height : 1.6 ; font-weight : 600 ; }
. file-error { margin : 0 ; color : #fca5a5 ; font-size : 12 px ; line-height : 1.4 ; }
. actions { padding : 16 px 20 px 32 px ; display : flex ; flex-direction : column ; gap : 12 px ; }
. auto-row { display : flex ; align-items : center ; justify-content : center ; gap : 10 px ; color : #a9b0c0 ; font-size : 14 px ; font-weight : 600 ; user-select : none ; cursor : pointer ; }
. auto-row input { width : 18 px ; height : 18 px ; accent-color : #6366f1 ; cursor : pointer ; }
. next-btn { width : 100 % ; padding : 16 px 24 px ; border-radius : 16 px ; font-size : 16 px ; font-weight : 7 00; cursor : pointer ; border : none ; color : #fff ; background : linear-gradient ( 135 deg , #5b8cff 0 % , #38c6ff 100 % ); box-shadow : 0 8 px 24 px -6 px rgba ( 72 , 150 , 255 , 0.4 ); }
. next-btn-flip {
background : linear-gradient ( 135 deg , #f59e0b 0 % , #d97706 100 % );
box-shadow : 0 8 px 24 px -6 px rgba ( 245 , 158 , 11 , 0.4 );
}
. next-btn-disabled {
opacity : 0.5 ;
cursor : wait ;
pointer-events : none ;
background : linear-gradient ( 135 deg , #475569 0 % , #64748b 100 % );
box-shadow : none ;
}
. cancel-btn { position : relative ; overflow : hidden ; width : 100 % ; padding : 16 px 24 px ; border-radius : 16 px ; border : 1 px solid rgba ( 239 , 68 , 68 , 0.35 ); background : rgba ( 239 , 68 , 68 , 0.08 ); color : #fca5a5 ; font-size : 16 px ; font-weight : 700 ; cursor : pointer ; touch-action : none ; user-select : none ; -webkit- user-select : none ; -webkit- touch-callout : none ; -webkit- tap-highlight-color : transparent ; }
. cancel-btn : disabled {
opacity : 0.4 ;
cursor : not-allowed ;
pointer-events : non e;
}
. cancel-fill { position : absolute ; left : 0 ; top : 0 ; bottom : 0 ; width : 0 % ; background : rgba ( 23 9, 68 , 68 , 0.35 ); pointer-events : none ; }
. cancel-label { position : relative ; }
. hold-hint { margin : -4 px 0 0 ; text-align : center ; color : rgba ( 139 , 147 , 161 , 0.6 ) ; font-size : 11 px ; }
. home-btn { width : 100 % ; padding : 16 px 24 px ; border-radius : 16 px ; font-size : 16 px ; font-weight : 700 ; cursor : pointer ; border : none ; color : #fff ; background : linear-gradient ( 135 deg , #22c55e 0 % , #16a34a 100 % ); box-shadow : 0 8 px 24 px -6 px rgba ( 34 , 197 , 94 , 0.4 ); }
. clear-hint { color : #f59e0b ; }
. flip-hint { color : #f59e0b ; }
. auto-warning {
margin : -4 px 0 8 px ;
text-align : center ;
color : #f59e0b ;
font-size : 13 px ;
font-weight : 60 0;
}
. page-container {
width : 100 % ; min-height : 100 dvh ;
display : flex ; flex-direction : column ;
background : radial-gradient ( 120 % 60 % at 50 % -10 % , rgba ( 99 , 102 , 241 , 0.18 ) 0 % , transparent 60 % );
font-family : system-ui , - apple-system , sans-serif ;
color : #f5f7fa ;
}
. header { padding : 24 px 20 px 8 px ; display : flex ; align-items : center ; position : relative ; }
. back-btn {
background : none ; border : none ; color : #6366f1 ;
font-size : 15 px ; font-weight : 6 00; cursor : pointer ;
padding : 8 px 4 px ; margin-right : auto ; min-height : 44 px ;
}
. back-btn : disabled { opacity : 0.3 ; cursor : not-allowed ; }
. header h1 {
margin : 0 ; font-size : 28 px ; font-weight : 700 ;
position : absolute ; left : 50 % ; transform : translateX ( -50 % ) ;
white-space : nowrap ;
}
. list { flex : 1 ; padding : 24 px 20 px 16 px ; display : flex ; flex-direction : column ; gap : 14 px ; overflow-y : auto ; }
. file-card {
border-radius : 16 px ; background : rgba ( 255 , 255 , 255 , 0.04 );
border : 1 px solid rgba ( 255 , 255 , 255 , 0.08 ) ;
padding : 14 px ; display : flex ; flex-direction : column ; gap : 8 px ;
transition : border-color 0.2 s ease , background 0.2 s eas e;
}
. file-card . current { border-color : rgba ( 99 , 102 , 241 , 0.45 ) ; background : rgba ( 9 9, 102 , 241 , 0.07 ); }
. file-row { display : flex ; align-items : center ; gap : 12 px ; min-width : 0 ; }
. file-name {
flex : 1 ; min-width : 0 ; font-size : 15 px ; font-weight : 500 ;
overflow : hidden ; text-overflow : ellipsis ; white-space : nowrap ;
}
. file-status { flex-shrink : 0 ; font-size : 14 px ; font-weight : 700 ; }
. type-badge {
flex-shrink : 0 ; padding : 3 px 7 px ; border-radius : 6 px ;
background : rgba ( 99 , 102 , 241 , 0.2 ); border : 1 px solid rgba ( 99 , 102 , 241 , 0.35 );
color : #a5b4fc ; font-size : 10 px ; font-weight : 700 ;
letter-spacing : 0.04 em ; line-height : 1.2 ; user-select : none ;
}
. pickup-hint {
margin : 0 ; padding-left : 22 px ;
color : #eab308 ; font-size : 14 px ; line-height : 1.6 ; font-weight : 600 ;
}
. file-error { margin : 0 ; color : #fca5a5 ; font-size : 12 px ; line-height : 1.4 ; }
. actions { padding : 16 px 20 px 32 px ; display : flex ; flex-direction : column ; gap : 12 px ; }
. auto-row {
display : flex ; align-items : center ; justify-content : center ; gap : 10 px ;
color : #a9b0c0 ; font-size : 14 px ; font-weight : 600 ;
user-select : none ; cursor : pointer ;
}
. auto-row input { width : 18 px ; height : 18 px ; accent-color : #6366f1 ; cursor : pointer ; }
. next-btn {
width : 100 % ; padding : 16 px 24 px ; border-radius : 16 px ;
font-size : 16 px ; font-weight : 700 ; cursor : pointer ; border : none ; color : #fff ;
background : linear-gradient ( 135 deg , #5b8cff 0 % , #38c6ff 100 % );
box-shadow : 0 8 px 24 px -6 px rgba ( 72 , 150 , 255 , 0.4 );
}
. next-btn-clear {
background : linear-gradient ( 135 deg , #f59e0b 0 % , #d97706 100 % );
box-shadow : 0 8 px 24 px -6 px rgba ( 245 , 158 , 11 , 0.4 );
}
. next-btn-flip {
background : linear-gradient ( 135 deg , #f59e0b 0 % , #d97706 100 % );
box-shadow : 0 8 px 24 px -6 px rgba ( 245 , 158 , 11 , 0.4 );
}
. next-btn-disabled {
opacity : 0.5 ; cursor : wait ; pointer-events : none ;
background : linear-gradient ( 135 deg , #475569 0 % , #64748b 100 % );
box-shadow : none ;
}
. cancel-btn {
position : relative ; overflow : hidden ; width : 100 % ; padding : 16 px 24 px ;
border-radius : 16 px ; border : 1 px solid rgba ( 239 , 68 , 68 , 0.35 );
background : rgba ( 239 , 68 , 68 , 0.08 ); color : #fca5a5 ;
font-size : 16 px ; font-weight : 700 ; cursor : pointer ;
touch-action : none ; user-select : none ;
-webkit- user-select : none ; -webkit- touch-callout : none ;
-webkit- tap-highlight-color : transparent ;
}
. cancel-btn : disabled { opacity : 0.4 ; cursor : not-allowed ; pointer-events : none ; }
. cancel-fill {
position : absolute ; left : 0 ; top : 0 ; bottom : 0 ; width : 0 % ;
background : rgba ( 239 , 68 , 68 , 0.35 ); pointer-events : none ;
}
. cancel-label { position : relative ; }
. hold-hint { margin : -4 px 0 0 ; text-align : center ; color : rgba ( 139 , 147 , 161 , 0.6 ); font-size : 11 px ; }
. home-btn {
width : 100 % ; padding : 16 px 24 px ; border-radius : 16 px ;
font-size : 16 px ; font-weight : 700 ; cursor : pointer ; border : none ; color : #fff ;
background : linear-gradient ( 135 deg , #22c55e 0 % , #16a34a 100 % );
box-shadow : 0 8 px 24 px -6 px rgba ( 34 , 197 , 94 , 0.4 );
}
</ style >