2026-08-03 12:36:16 +08:00
<!doctype html>
< html lang = "zh-CN" data-theme = "system" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
< link rel = "icon" href = "data:," >
2026-08-03 15:35:31 +08:00
< title > 采购工具 · 采购执行原型</ title >
2026-08-03 12:36:16 +08:00
< style >
: root {
color-scheme : light dark ;
--surface-base : #f3f3f3 ;
--surface-layer : rgba ( 255 , 255 , 255 , .86 );
--surface-raised : #ffffff ;
--surface-muted : #f8f8f8 ;
--text-primary : #1b1b1b ;
--text-secondary : #5d5d5d ;
--text-disabled : #8a8a8a ;
--accent : #005fb8 ;
--accent-hover : #00549f ;
--accent-pressed : #004578 ;
--accent-soft : #e7f3ff ;
--success : #0f6b38 ;
--success-soft : #e7f5ec ;
--caution : #8a5600 ;
--caution-soft : #fff4ce ;
--critical : #b10e1e ;
--critical-soft : #fde7e9 ;
--info : #005fb8 ;
--info-soft : #e7f3ff ;
--stroke : #d0d0d0 ;
--divider : #e5e5e5 ;
--focus : #000000 ;
--shadow : 0 8 px 24 px rgba ( 0 , 0 , 0 , .08 );
--space-1 : 4 px ;
--space-2 : 8 px ;
--space-3 : 12 px ;
--space-4 : 16 px ;
--space-5 : 20 px ;
--space-6 : 24 px ;
--radius-control : 4 px ;
--radius-card : 8 px ;
--motion-fast : 120 ms ;
}
html [ data-theme = "dark" ] {
--surface-base : #202020 ;
--surface-layer : rgba ( 44 , 44 , 44 , .94 );
--surface-raised : #2b2b2b ;
--surface-muted : #262626 ;
--text-primary : #f5f5f5 ;
--text-secondary : #c7c7c7 ;
--text-disabled : #8f8f8f ;
--accent : #60aef2 ;
--accent-hover : #79bcf5 ;
--accent-pressed : #4798de ;
--accent-soft : #12324b ;
--success : #6ccb8e ;
--success-soft : #173d27 ;
--caution : #f3c45b ;
--caution-soft : #493711 ;
--critical : #ff99a4 ;
--critical-soft : #4e1d22 ;
--info : #75b9f3 ;
--info-soft : #12324b ;
--stroke : #5c5c5c ;
--divider : #3c3c3c ;
--focus : #ffffff ;
--shadow : 0 8 px 24 px rgba ( 0 , 0 , 0 , .32 );
}
@ media ( prefers-color-scheme : dark ) {
html [ data-theme = "system" ] {
--surface-base : #202020 ;
--surface-layer : rgba ( 44 , 44 , 44 , .94 );
--surface-raised : #2b2b2b ;
--surface-muted : #262626 ;
--text-primary : #f5f5f5 ;
--text-secondary : #c7c7c7 ;
--text-disabled : #8f8f8f ;
--accent : #60aef2 ;
--accent-hover : #79bcf5 ;
--accent-pressed : #4798de ;
--accent-soft : #12324b ;
--success : #6ccb8e ;
--success-soft : #173d27 ;
--caution : #f3c45b ;
--caution-soft : #493711 ;
--critical : #ff99a4 ;
--critical-soft : #4e1d22 ;
--info : #75b9f3 ;
--info-soft : #12324b ;
--stroke : #5c5c5c ;
--divider : #3c3c3c ;
--focus : #ffffff ;
--shadow : 0 8 px 24 px rgba ( 0 , 0 , 0 , .32 );
}
}
* { box-sizing : border-box ; }
body {
margin : 0 ;
min-width : 320 px ;
min-height : 100 vh ;
color : var ( -- text - primary );
background : var ( -- surface - base );
font : 14 px / 1.45 "Segoe UI Variable" , "Segoe UI" , "Microsoft YaHei UI" , sans-serif ;
}
button , select , input { font : inherit ; }
a { color : inherit ; }
button , select , a { -webkit- tap-highlight-color : transparent ; }
: focus-visible { outline : 2 px solid var ( -- focus ); outline-offset : 2 px ; }
button : focus-visible , select : focus-visible , a : focus-visible { box-shadow : 0 0 0 4 px var ( -- accent - soft ); }
button , . button-link {
min-height : 32 px ;
border : 1 px solid var ( -- stroke );
border-radius : var ( -- radius - control );
padding : 5 px 14 px ;
color : var ( -- text - primary );
background : var ( -- surface - raised );
cursor : pointer ;
text-decoration : none ;
display : inline-flex ;
align-items : center ;
justify-content : center ;
gap : 8 px ;
}
button : hover : not ( : disabled ), . button-link : hover { background : var ( -- surface - muted ); }
button : active : not ( : disabled ), . button-link : active { transform : translateY ( 1 px ); }
button : disabled { color : var ( -- text - disabled ); background : var ( -- surface - muted ); cursor : not-allowed ; }
. primary { color : white ; border-color : transparent ; background : var ( -- accent ); }
. primary : hover : not ( : disabled ) { background : var ( -- accent - hover ); }
. primary : active : not ( : disabled ) { background : var ( -- accent - pressed ); }
. danger { color : var ( -- critical ); border-color : var ( -- critical ); background : transparent ; }
. prototype-banner {
padding : 6 px 16 px ;
color : #1b1b1b ;
background : #f7cf46 ;
border-bottom : 1 px solid #b08b00 ;
font-weight : 650 ;
text-align : center ;
}
. app-bar {
min-height : 52 px ;
display : flex ;
align-items : center ;
gap : 16 px ;
padding : 8 px 16 px ;
border-bottom : 1 px solid var ( -- divider );
background : var ( -- surface - layer );
}
. brand { font-size : 16 px ; font-weight : 650 ; white-space : nowrap ; }
. brand small { display : block ; color : var ( -- text - secondary ); font-size : 11 px ; font-weight : 400 ; }
. tabs { display : flex ; align-self : stretch ; align-items : stretch ; gap : 2 px ; }
. tabs a {
min-height : 40 px ;
padding : 9 px 16 px 7 px ;
display : flex ;
align-items : center ;
border-bottom : 3 px solid transparent ;
color : var ( -- text - secondary );
text-decoration : none ;
font-weight : 600 ;
}
. tabs a [ aria-current = "page" ] { color : var ( -- text - primary ); border-bottom-color : var ( -- accent ); }
. app-actions { margin-left : auto ; display : flex ; align-items : center ; gap : 8 px ; }
. app-actions label { color : var ( -- text - secondary ); font-size : 12 px ; }
. app-actions select { min-height : 32 px ; border : 1 px solid var ( -- stroke ); border-radius : 4 px ; background : var ( -- surface - raised ); color : var ( -- text - primary ); padding : 4 px 28 px 4 px 8 px ; }
main { max-width : 1480 px ; margin : 0 auto ; padding : 16 px ; }
. prototype-tools {
display : grid ;
2026-08-03 15:55:11 +08:00
grid-template-columns : minmax ( 240 px , 1 fr ) minmax ( 180 px , .55 fr ) auto ;
2026-08-03 12:36:16 +08:00
gap : 12 px ;
align-items : end ;
margin-bottom : 12 px ;
padding : 12 px ;
border : 1 px dashed var ( -- stroke );
border-radius : var ( -- radius - card );
background : var ( -- surface - layer );
}
. prototype-tools label { display : grid ; gap : 5 px ; color : var ( -- text - secondary ); font-size : 12 px ; }
. prototype-tools select { min-height : 34 px ; width : 100 % ; border : 1 px solid var ( -- stroke ); border-radius : 4 px ; color : var ( -- text - primary ); background : var ( -- surface - raised ); padding : 5 px 8 px ; }
. tool-buttons { display : flex ; gap : 8 px ; flex-wrap : wrap ; }
. status-strip {
display : grid ;
2026-08-03 15:35:31 +08:00
grid-template-columns : repeat ( 3 , minmax ( 0 , 1 fr )) minmax ( 260 px , .9 fr );
2026-08-03 12:36:16 +08:00
border : 1 px solid var ( -- stroke );
border-radius : var ( -- radius - card );
background : var ( -- surface - raised );
overflow : hidden ;
box-shadow : var ( -- shadow );
}
. health { display : grid ; grid-template-columns : auto 1 fr ; gap : 4 px 9 px ; padding : 12 px 16 px ; border-right : 1 px solid var ( -- divider ); }
2026-08-03 15:35:31 +08:00
. health : nth-child ( 3 ) { border-right : 1 px solid var ( -- divider ); }
2026-08-03 12:36:16 +08:00
. health-mark { width : 10 px ; height : 10 px ; border-radius : 50 % ; background : currentColor ; margin-top : 5 px ; }
. health strong { font-weight : 650 ; }
. health span : last-child { grid-column : 2 ; color : var ( -- text - secondary ); font-size : 12 px ; }
2026-08-03 15:35:31 +08:00
. session-control { display : grid ; grid-template-columns : minmax ( 0 , 1 fr ) auto ; gap : 4 px 12 px ; align-items : center ; padding : 10 px 12 px ; }
. session-copy { min-width : 0 ; }
. session-copy span { display : block ; color : var ( -- text - secondary ); font-size : 11 px ; }
. session-copy strong { display : block ; overflow-wrap : anywhere ; }
. session-control . primary { min-width : 112 px ; }
. disabled-reason { grid-column : 1 / -1 ; color : var ( -- text - secondary ); font-size : 11 px ; }
2026-08-03 12:36:16 +08:00
. tone-success { color : var ( -- success ); }
. tone-info { color : var ( -- info ); }
. tone-caution { color : var ( -- caution ); }
. tone-critical { color : var ( -- critical ); }
. banner {
display : grid ;
2026-08-03 15:35:31 +08:00
grid-template-columns : auto minmax ( 0 , 1 fr ) auto ;
2026-08-03 12:36:16 +08:00
gap : 10 px ;
2026-08-03 15:35:31 +08:00
align-items : center ;
2026-08-03 12:36:16 +08:00
margin-top : 12 px ;
padding : 12 px 14 px ;
border : 1 px solid currentColor ;
border-radius : var ( -- radius - card );
background : var ( -- info - soft );
color : var ( -- info );
}
. banner [ data-tone = "success" ] { background : var ( -- success - soft ); color : var ( -- success ); }
. banner [ data-tone = "caution" ] { background : var ( -- caution - soft ); color : var ( -- caution ); }
. banner [ data-tone = "critical" ] { background : var ( -- critical - soft ); color : var ( -- critical ); }
. banner-symbol { font-weight : 800 ; }
. banner strong { display : block ; margin-bottom : 2 px ; }
. banner p { margin : 0 ; color : var ( -- text - primary ); }
2026-08-03 15:35:31 +08:00
. banner button [ hidden ] { display : none ; }
. workspace { display : grid ; grid-template-columns : minmax ( 0 , 1.85 fr ) minmax ( 320 px , 1 fr ); gap : 12 px ; margin-top : 12 px ; align-items : stretch ; }
2026-08-03 15:55:11 +08:00
. left-pane { min-width : 0 ; min-height : 0 ; }
2026-08-03 15:35:31 +08:00
. stack { display : grid ; grid-template-rows : auto minmax ( 240 px , 1 fr ); gap : 12 px ; min-width : 0 ; }
2026-08-03 15:55:11 +08:00
. stack [ hidden ], . detail-workspace [ hidden ] { display : none ; }
2026-08-03 12:36:16 +08:00
. card { border : 1 px solid var ( -- stroke ); border-radius : var ( -- radius - card ); background : var ( -- surface - raised ); box-shadow : 0 2 px 8 px rgba ( 0 , 0 , 0 , .04 ); overflow : hidden ; }
. card-header { display : flex ; align-items : start ; justify-content : space-between ; gap : 12 px ; padding : 14 px 16 px 10 px ; border-bottom : 1 px solid var ( -- divider ); }
. card-header h1 , . card-header h2 { margin : 0 ; font-size : 17 px ; line-height : 1.35 ; }
. card-header p { margin : 3 px 0 0 ; color : var ( -- text - secondary ); font-size : 12 px ; }
. badge { display : inline-flex ; align-items : center ; gap : 6 px ; min-height : 24 px ; padding : 2 px 8 px ; border : 1 px solid currentColor ; border-radius : 999 px ; font-size : 12 px ; font-weight : 650 ; white-space : nowrap ; }
. badge [ data-tone = "info" ] { color : var ( -- info ); background : var ( -- info - soft ); }
. badge [ data-tone = "success" ] { color : var ( -- success ); background : var ( -- success - soft ); }
. badge [ data-tone = "caution" ] { color : var ( -- caution ); background : var ( -- caution - soft ); }
. badge [ data-tone = "critical" ] { color : var ( -- critical ); background : var ( -- critical - soft ); }
. card-body { padding : 14 px 16 px 16 px ; }
2026-08-03 15:35:31 +08:00
. task-layout { display : grid ; grid-template-columns : minmax ( 0 , 2 fr ) minmax ( 180 px , 1 fr ); gap : 16 px ; align-items : start ; }
. task-copy { min-width : 0 ; }
2026-08-03 12:36:16 +08:00
. task-grid { display : grid ; grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr )); gap : 10 px 20 px ; margin : 0 ; }
. task-grid div { min-width : 0 ; }
. task-grid dt { color : var ( -- text - secondary ); font-size : 12 px ; }
. task-grid dd { margin : 2 px 0 0 ; overflow-wrap : anywhere ; font-variant-numeric : tabular-nums ; }
. task-grid . span-2 { grid-column : 1 / -1 ; }
. steps { list-style : none ; margin : 14 px 0 0 ; padding : 0 ; display : grid ; gap : 7 px ; }
. steps li { display : grid ; grid-template-columns : 20 px 1 fr auto ; gap : 8 px ; align-items : start ; color : var ( -- text - secondary ); }
. step-mark { width : 18 px ; height : 18 px ; border : 1 px solid var ( -- stroke ); border-radius : 50 % ; display : grid ; place-items : center ; font-size : 11 px ; }
. steps li . done { color : var ( -- success ); }
. steps li . active { color : var ( -- text - primary ); font-weight : 650 ; }
. steps li . active . step-mark { border-color : var ( -- accent ); box-shadow : inset 0 0 0 4 px var ( -- accent - soft ); }
. steps small { color : var ( -- text - secondary ); font-weight : 400 ; }
2026-08-03 15:35:31 +08:00
. product-preview { margin : 0 ; min-width : 0 ; border : 1 px solid var ( -- divider ); border-radius : 6 px ; overflow : hidden ; background : var ( -- surface - muted ); }
. preview-stage { aspect-ratio : 4 / 3 ; min-height : 148 px ; display : grid ; place-items : center ; padding : 12 px ; border-bottom : 1 px solid var ( -- divider ); }
. preview-stage svg { width : 100 % ; height : 100 % ; max-height : 210 px ; }
. preview-empty { color : var ( -- text - secondary ); text-align : center ; }
. preview-empty strong { display : block ; color : var ( -- text - primary ); margin-bottom : 4 px ; }
. product-preview figcaption { padding : 9 px 10 px ; color : var ( -- text - secondary ); font-size : 11 px ; }
. product-preview figcaption span , . product-preview figcaption time { display : block ; }
. log-card { display : flex ; min-height : 240 px ; flex-direction : column ; }
. log-tools { display : flex ; align-items : center ; justify-content : flex-end ; gap : 10 px ; flex-wrap : wrap ; }
. inline-metrics { display : flex ; gap : 10 px ; flex-wrap : wrap ; }
. inline-metrics span { color : var ( -- text - secondary ); font-size : 11 px ; white-space : nowrap ; }
. inline-metrics strong { color : var ( -- text - primary ); font-variant-numeric : tabular-nums ; }
. log-list { list-style : none ; padding : 0 ; margin : 0 ; min-height : 210 px ; max-height : 360 px ; overflow : auto ; }
2026-08-03 12:36:16 +08:00
. log-list li { display : grid ; grid-template-columns : 64 px 78 px 1 fr ; gap : 8 px ; padding : 8 px 16 px ; border-bottom : 1 px solid var ( -- divider ); }
. log-list li : last-child { border-bottom : 0 ; }
. log-list time { color : var ( -- text - secondary ); font-variant-numeric : tabular-nums ; }
. log-level { font-size : 12 px ; font-weight : 650 ; }
2026-08-03 15:35:31 +08:00
. record-card { min-width : 0 ; min-height : 0 ; display : flex ; flex-direction : column ; }
. record-actions { display : flex ; align-items : center ; gap : 8 px ; }
. table-wrap { min-height : 420 px ; max-height : 720 px ; overflow : auto ; flex : 1 ; }
. record-table { width : 100 % ; border-collapse : separate ; border-spacing : 0 ; table-layout : fixed ; }
. record-table th { position : sticky ; top : 0 ; z-index : 1 ; padding : 9 px 12 px ; color : var ( -- text - secondary ); background : var ( -- surface - muted ); border-bottom : 1 px solid var ( -- stroke ); text-align : left ; font-size : 12 px ; }
. record-table th : last-child { width : 112 px ; }
. record-table td { padding : 10 px 12 px ; border-bottom : 1 px solid var ( -- divider ); vertical-align : top ; }
. record-table tbody tr { cursor : default ; }
. record-table tbody tr : hover { background : var ( -- surface - muted ); }
. record-table tbody tr [ aria-selected = "true" ] { background : var ( -- accent - soft ); }
. record-table tbody tr : focus-visible { position : relative ; outline-offset : -3 px ; }
. record-title { display : block ; font-weight : 650 ; overflow-wrap : anywhere ; }
. record-meta { display : block ; margin-top : 3 px ; color : var ( -- text - secondary ); font-size : 11 px ; font-variant-numeric : tabular-nums ; }
. record-status { display : inline-flex ; padding : 2 px 7 px ; border : 1 px solid currentColor ; border-radius : 999 px ; font-size : 11 px ; font-weight : 650 ; }
. record-status [ data-tone = "success" ] { color : var ( -- success ); }
. record-status [ data-tone = "info" ] { color : var ( -- info ); }
. record-status [ data-tone = "caution" ] { color : var ( -- caution ); }
. record-status [ data-tone = "critical" ] { color : var ( -- critical ); }
. table-empty { padding : 28 px 16 px ; color : var ( -- text - secondary ); text-align : center ; }
. table-help { margin : 0 ; padding : 9 px 12 px ; color : var ( -- text - secondary ); border-top : 1 px solid var ( -- divider ); font-size : 11 px ; }
. context-menu { position : fixed ; z-index : 20 ; min-width : 150 px ; padding : 4 px ; border : 1 px solid var ( -- stroke ); border-radius : 6 px ; background : var ( -- surface - raised ); box-shadow : var ( -- shadow ); }
. context-menu [ hidden ] { display : none ; }
. context-menu button { width : 100 % ; justify-content : flex-start ; border-color : transparent ; }
dialog { width : min ( 560 px , calc ( 100 vw - 32 px )); max-height : calc ( 100 vh - 32 px ); overflow : auto ; border : 1 px solid var ( -- stroke ); border-radius : 8 px ; padding : 0 ; color : var ( -- text - primary ); background : var ( -- surface - raised ); box-shadow : 0 24 px 80 px rgba ( 0 , 0 , 0 , .3 ); }
2026-08-03 12:36:16 +08:00
dialog :: backdrop { background : rgba ( 0 , 0 , 0 , .42 ); }
. dialog-body { padding : 20 px ; }
. dialog-body h2 { margin : 0 0 8 px ; font-size : 18 px ; }
. dialog-body p { margin : 0 0 10 px ; }
. dialog-body code { overflow-wrap : anywhere ; }
. dialog-actions { display : flex ; justify-content : flex-end ; gap : 8 px ; padding : 12 px 20 px ; border-top : 1 px solid var ( -- divider ); }
2026-08-03 15:55:11 +08:00
. detail-workspace { min-height : 540 px ; height : 100 % ; display : flex ; flex-direction : column ; }
. detail-header { align-items : center ; }
. detail-header-actions { display : flex ; align-items : center ; justify-content : flex-end ; gap : 8 px ; flex-wrap : wrap ; }
. shortcut { margin-left : 5 px ; padding : 1 px 5 px ; border : 1 px solid var ( -- stroke ); border-bottom-width : 2 px ; border-radius : 3 px ; color : var ( -- text - secondary ); background : var ( -- surface - muted ); font-size : 11 px ; }
. detail-scroll { min-height : 0 ; overflow : auto ; flex : 1 ; }
. record-detail-grid { display : grid ; grid-template-columns : minmax ( 0 , 3 fr ) minmax ( 240 px , 2 fr ); gap : 16 px ; padding : 16 px 20 px ; }
. detail-feedback { margin : 16 px 20 px 0 ; padding : 16 px ; border : 1 px solid var ( -- stroke ); border-radius : 6 px ; background : var ( -- surface - muted ); }
. detail-feedback [ hidden ] { display : none ; }
. detail-feedback strong , . detail-feedback span { display : block ; }
. detail-feedback span { margin-top : 4 px ; color : var ( -- text - secondary ); }
. detail-feedback button { margin-top : 12 px ; }
2026-08-03 15:35:31 +08:00
. detail-panel { min-width : 0 ; }
. detail-panel h3 , . record-result h3 { margin : 0 0 9 px ; font-size : 14 px ; }
. detail-list { display : grid ; grid-template-columns : 104 px minmax ( 0 , 1 fr ); gap : 7 px 12 px ; margin : 0 ; }
. detail-list dt { color : var ( -- text - secondary ); }
. detail-list dd { margin : 0 ; overflow-wrap : anywhere ; }
. timeline { margin : 14 px 0 0 ; padding-left : 20 px ; color : var ( -- text - secondary ); }
. timeline li + li { margin-top : 5 px ; }
. evidence-grid { display : grid ; grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr )); gap : 8 px ; }
. evidence-card { margin : 0 ; border : 1 px solid var ( -- divider ); border-radius : 6 px ; overflow : hidden ; background : var ( -- surface - muted ); }
. evidence-card : only-child { grid-column : 1 / -1 ; }
. evidence-card svg { display : block ; width : 100 % ; aspect-ratio : 4 / 3 ; }
. evidence-card figcaption { padding : 8 px 10 px ; color : var ( -- text - secondary ); font-size : 11 px ; }
. record-result { padding : 14 px 20 px 18 px ; border-top : 1 px solid var ( -- divider ); }
. result-value { margin : 0 ; padding : 12 px ; border : 1 px solid var ( -- divider ); border-radius : 6 px ; background : var ( -- surface - muted ); font-weight : 650 ; overflow-wrap : anywhere ; }
. result-note { margin : 8 px 0 0 ; color : var ( -- text - secondary ); font-size : 12 px ; }
2026-08-03 12:36:16 +08:00
. sr-only { position : absolute ; width : 1 px ; height : 1 px ; padding : 0 ; margin : -1 px ; overflow : hidden ; clip : rect ( 0 , 0 , 0 , 0 ); white-space : nowrap ; border : 0 ; }
@ media ( max-width : 1007px ) {
. workspace { grid-template-columns : 1 fr ; }
2026-08-03 15:35:31 +08:00
. stack { grid-template-rows : auto auto ; }
. record-card { min-height : 480 px ; }
2026-08-03 15:55:11 +08:00
. workspace [ data-mode = "record" ] . record-card { display : none ; }
2026-08-03 12:36:16 +08:00
. log-list { max-height : 220 px ; }
}
@ media ( max-width : 640px ) {
. app-bar { align-items : stretch ; flex-wrap : wrap ; gap : 4 px 12 px ; }
. tabs { order : 3 ; width : 100 % ; }
. tabs a { flex : 1 ; justify-content : center ; padding-inline : 8 px ; }
. app-actions label { position : absolute ; clip : rect ( 0 , 0 , 0 , 0 ); }
main { padding : 8 px ; }
. prototype-tools { grid-template-columns : 1 fr ; }
. status-strip { grid-template-columns : 1 fr ; }
. health { border-right : 0 ; border-bottom : 1 px solid var ( -- divider ); }
2026-08-03 15:35:31 +08:00
. health : nth-child ( 3 ) { border-right : 0 ; }
. session-control { grid-template-columns : 1 fr ; }
. session-control . primary { width : 100 % ; min-height : 40 px ; }
. banner { grid-template-columns : auto minmax ( 0 , 1 fr ); }
. banner button { grid-column : 1 / -1 ; width : 100 % ; }
. task-layout { grid-template-columns : 1 fr ; }
2026-08-03 12:36:16 +08:00
. task-grid { grid-template-columns : 1 fr ; }
. task-grid . span-2 { grid-column : auto ; }
2026-08-03 15:35:31 +08:00
. preview-stage { aspect-ratio : 16 / 9 ; }
. card-header { align-items : stretch ; flex-direction : column ; }
. log-tools , . record-actions { justify-content : flex-start ; }
2026-08-03 12:36:16 +08:00
. log-list li { grid-template-columns : 58 px 1 fr ; }
. log-list li span : last-child { grid-column : 1 / -1 ; }
2026-08-03 15:55:11 +08:00
. record-detail-grid { grid-template-columns : 1 fr ; }
. detail-header-actions { align-items : stretch ; justify-content : flex-start ; }
. detail-header-actions . badge { white-space : normal ; }
2026-08-03 15:35:31 +08:00
. evidence-grid { grid-template-columns : 1 fr ; }
. detail-list { grid-template-columns : 1 fr ; gap : 2 px ; }
. detail-list dd { margin-bottom : 7 px ; }
2026-08-03 12:36:16 +08:00
}
@ media ( prefers-reduced-motion : reduce ) {
*, * :: before , * :: after { scroll-behavior : auto !important ; transition-duration : .01 ms !important ; animation-duration : .01 ms !important ; animation-iteration-count : 1 !important ; }
}
@ media ( forced-colors : active ) {
. prototype-banner , . banner , . badge , . primary , . health-mark { forced-color-adjust : auto ; }
2026-08-03 15:35:31 +08:00
. card , . status-strip , . prototype-tools { box-shadow : none ; }
2026-08-03 12:36:16 +08:00
. health-mark { border : 1 px solid ButtonText ; }
}
</ style >
</ head >
< body >
< div class = "prototype-banner" role = "note" > PROTOTYPE - 仅供枚举交互,非实现依据;全部是假数据,不连接 ADB、web 服务或拼多多</ div >
< header class = "app-bar" >
2026-08-03 15:35:31 +08:00
< div class = "brand" > 采购工具< small > Windows / PySide6 前置原型</ small ></ div >
2026-08-03 12:36:16 +08:00
< nav class = "tabs" aria-label = "主要页面" >
< a href = "desk-execution.html" aria-current = "page" > 采购执行</ a >
2026-08-03 15:35:31 +08:00
< a href = "desk-device-settings.html" > 配置</ a >
2026-08-03 12:36:16 +08:00
</ nav >
< div class = "app-actions" >
< label for = "theme" > 主题</ label >
< select id = "theme" aria-label = "原型主题" >
< option value = "system" > 跟随系统</ option >
< option value = "light" > 浅色</ option >
< option value = "dark" > 深色</ option >
</ select >
</ div >
</ header >
< main >
< section class = "prototype-tools" aria-labelledby = "prototype-tools-title" >
< label id = "prototype-tools-title" for = "scenario" > 原型状态切换器(仅改变本页假数据)
< select id = "scenario" >
< option value = "not_ready" > 未就绪 · web 不可达</ option >
< option value = "version_mismatch" > 未就绪 · 拼多多版本失配</ option >
< option value = "idle" selected > 已就绪 · 尚未轮询</ option >
< option value = "polling" > 轮询中 · 暂无任务</ option >
2026-08-04 16:25:34 +08:00
< option value = "claimed" > 单趟执行 · 已领取</ option >
< option value = "selecting" > 单趟执行 · 选择规格</ option >
< option value = "gate1" > 单趟执行 · 闸门一</ option >
< option value = "quantity_gate2" > 单趟执行 · 数量与闸门二</ option >
< option value = "confirm_gate3" > 单趟执行 · 确认页与闸门三</ option >
< option value = "fencing" > 单趟执行 · 申请围栏中</ option >
2026-08-03 12:36:16 +08:00
< option value = "fence_failed" > 围栏失败或响应不明 · 不点击</ option >
< option value = "fenced" > 围栏后 · 核对同一提交</ option >
< option value = "waiting_payment" > 明确已创建 · 等人付款</ option >
< option value = "uncertain" > 点击结果不明 · 可能已创建</ option >
< option value = "security" > 安全校验 · 已停止</ option >
< option value = "payment_handoff" > 外部支付交接 · 已停止</ option >
< option value = "manual" > 围栏前异常 · 待人工</ option >
< option value = "failed_stop" > 连续失败 · 轮询已停止</ option >
</ select >
</ label >
2026-08-03 15:55:11 +08:00
< label for = "detail-state" > 记录详情状态(仅演示读取反馈)
< select id = "detail-state" >
< option value = "normal" selected > 正常</ option >
< option value = "loading" > 加载中</ option >
< option value = "error" > 加载失败</ option >
< option value = "missing" > 记录不存在</ option >
</ select >
</ label >
2026-08-03 12:36:16 +08:00
< div class = "tool-buttons" >
< button type = "button" id = "reset" > 复位假数据</ button >
< button type = "button" id = "close-demo" > 演示关闭窗口</ button >
</ div >
</ section >
< section class = "status-strip" aria-label = "就绪状态" >
< div class = "health" id = "web-health" >
< span class = "health-mark" aria-hidden = "true" ></ span >< strong > web 服务</ strong >< span > 已连接 · 假环境</ span >
</ div >
< div class = "health" id = "device-health" >
< span class = "health-mark" aria-hidden = "true" ></ span >< strong > Android 设备</ strong >< span > FAKE-DEVICE-01 · 已解锁(模拟)</ span >
</ div >
< div class = "health" id = "version-health" >
< span class = "health-mark" aria-hidden = "true" ></ span >< strong > 拼多多版本</ strong >< span > 实际 / 已取证:均待 T-103 真机取证</ span >
</ div >
2026-08-03 15:35:31 +08:00
< div class = "session-control" aria-label = "轮询会话控制" >
< div class = "session-copy" >< span > 会话状态</ span >< strong id = "session-state" > 未启动</ strong ></ div >
< button type = "button" class = "primary" id = "primary-action" > 开始轮询</ button >
< div class = "session-copy" >< span > 当前步骤</ span >< strong id = "current-step" > 等待开始</ strong ></ div >
< div class = "disabled-reason" id = "action-reason" > web、设备和已取证 App 版本均就绪后才能开始。</ div >
</ div >
2026-08-03 12:36:16 +08:00
</ section >
< section class = "banner" id = "state-banner" data-tone = "success" aria-live = "polite" aria-atomic = "true" >
< span class = "banner-symbol" aria-hidden = "true" > i</ span >
< div >< strong id = "banner-title" > 设备已就绪</ strong >< p id = "banner-text" > 可以开始定时轮询。当前页面不会连接真实设备。</ p ></ div >
2026-08-03 15:35:31 +08:00
< button type = "button" id = "context-action" hidden > 查看说明</ button >
2026-08-03 12:36:16 +08:00
</ section >
2026-08-03 15:55:11 +08:00
< div class = "workspace" id = "workspace" data-mode = "live" >
< div class = "left-pane" >
< div class = "stack" id = "live-workspace" >
2026-08-03 12:36:16 +08:00
< section class = "card" aria-labelledby = "task-title" >
< div class = "card-header" >
< div >< h1 id = "task-title" > 当前任务</ h1 >< p id = "task-subtitle" > 尚未领取任务</ p ></ div >
< span class = "badge" id = "leg-badge" data-tone = "success" > 空闲</ span >
</ div >
< div class = "card-body" >
2026-08-03 15:35:31 +08:00
< div class = "task-layout" >
< div class = "task-copy" >
< dl class = "task-grid" >
< div >< dt > 任务编号</ dt >< dd id = "task-id" > —</ dd ></ div >
< div >< dt > 剩余租约</ dt >< dd id = "lease" > —</ dd ></ div >
< div class = "span-2" >< dt > 商品</ dt >< dd id = "product" > 暂无商品;轮询会在安全就绪后领取一条任务</ dd ></ div >
< div >< dt > 颜色分类</ dt >< dd id = "color" > —</ dd ></ div >
< div >< dt > 尺码 / 数量</ dt >< dd id = "size-qty" > —</ dd ></ div >
2026-08-04 16:25:34 +08:00
< div >< dt > 闸门一单价</ dt >< dd id = "gate1-price" > —</ dd ></ div >
2026-08-03 15:35:31 +08:00
< div >< dt > 金额上限</ dt >< dd id = "price-cap" > —</ dd ></ div >
< div class = "span-2" >< dt > 提交围栏</ dt >< dd id = "submission" > 未建立</ dd ></ div >
</ dl >
< ol class = "steps" id = "steps" aria-label = "执行步骤" ></ ol >
</ div >
< figure class = "product-preview" aria-labelledby = "preview-caption" >
< div class = "preview-stage" >
< div class = "preview-empty" id = "preview-empty" >< strong > 暂无商品预览</ strong > 只有取得可信图片证据后才展示</ div >
< svg id = "product-preview-image" viewBox = "0 0 320 240" role = "img" aria-label = "原型假商品预览,不是真实拼多多图片" hidden >
< rect width = "320" height = "240" fill = "#e7f3ff" />
< rect x = "28" y = "24" width = "264" height = "192" rx = "12" fill = "#ffffff" stroke = "#9ac8ee" stroke-width = "2" />
< path d = "M114 72l30-18h32l30 18 33 12-18 43-23-10v61h-76v-61l-23 10-18-43 33-12z" fill = "#f5f5f5" stroke = "#65788a" stroke-width = "3" />
< path d = "M144 55c3 20 29 20 32 0" fill = "none" stroke = "#65788a" stroke-width = "3" />
< text x = "160" y = "208" text-anchor = "middle" fill = "#3a5268" font-size = "13" font-family = "Segoe UI, sans-serif" > 内联假证据 · 不访问拼多多</ text >
</ svg >
</ div >
< figcaption id = "preview-caption" >< span id = "preview-source" > 来源:尚无可信图片</ span >< time id = "preview-time" > 采集时间:—</ time ></ figcaption >
</ figure >
</ div >
2026-08-03 12:36:16 +08:00
</ div >
</ section >
2026-08-03 15:35:31 +08:00
< section class = "card log-card" aria-labelledby = "log-title" >
< div class = "card-header" >
< div >< h2 id = "log-title" > 滚动日志</ h2 >< p > 最新事件在上;不含凭据、地址、手机号或页面全文</ p ></ div >
< div class = "log-tools" >
< div class = "inline-metrics" aria-label = "会话指标" >< span > 下次轮询 < strong id = "countdown" > —</ strong ></ span >< span > 连续失败 < strong id = "failure-count" > 0 / 3</ strong ></ span >< span > 本次完成 < strong id = "completed-count" > 0</ strong ></ span ></ div >
< button type = "button" id = "export-log" > 导出假诊断摘要</ button >
</ div >
2026-08-03 12:36:16 +08:00
</ div >
2026-08-03 15:35:31 +08:00
< ul class = "log-list" id = "logs" aria-live = "polite" aria-relevant = "additions" ></ ul >
2026-08-03 12:36:16 +08:00
</ section >
2026-08-03 15:55:11 +08:00
</ div >
< section class = "card detail-workspace" id = "record-detail-pane" aria-labelledby = "record-detail-title" aria-busy = "false" hidden >
< div class = "card-header detail-header" >
< div >< h1 id = "record-detail-title" > 执行记录详情</ h1 >< p id = "record-detail-meta" > 选择右侧记录查看历史快照</ p ></ div >
< div class = "detail-header-actions" >
< span class = "badge" id = "live-task-state" data-tone = "success" aria-live = "polite" > 实时:未启动</ span >
< button type = "button" id = "return-live" > 返回当前任务 < span class = "shortcut" aria-hidden = "true" > Esc</ span ></ button >
</ div >
</ div >
< div class = "detail-feedback" id = "detail-feedback" role = "status" aria-live = "polite" hidden >
< strong id = "detail-feedback-title" ></ strong >
< span id = "detail-feedback-text" ></ span >
< button type = "button" id = "retry-detail" hidden > 重新读取记录</ button >
</ div >
< div class = "detail-scroll" id = "detail-content" >
< div class = "record-detail-grid" >
< section class = "detail-panel" aria-labelledby = "original-copy-title" >
< h3 id = "original-copy-title" > 原始文字</ h3 >
< dl class = "detail-list" >
< dt > 任务编号</ dt >< dd id = "detail-task-id" ></ dd >
< dt > 商品标题</ dt >< dd id = "detail-product" ></ dd >
< dt > 目标规格</ dt >< dd id = "detail-spec" ></ dd >
< dt > 金额边界</ dt >< dd id = "detail-cap" ></ dd >
< dt > 提交围栏</ dt >< dd id = "detail-submission" ></ dd >
< dt > 原始说明</ dt >< dd id = "detail-note" ></ dd >
</ dl >
< ol class = "timeline" id = "detail-timeline" aria-label = "执行时间线" ></ ol >
</ section >
< section class = "detail-panel" aria-labelledby = "evidence-title" >
< h3 id = "evidence-title" > 图片证据</ h3 >
< div class = "evidence-grid" id = "detail-evidence" ></ div >
</ section >
</ div >
< section class = "record-result" aria-labelledby = "result-title" >
< h3 id = "result-title" > 采购结果</ h3 >
< p class = "result-value" id = "detail-result" ></ p >
< p class = "result-note" id = "detail-result-note" ></ p >
</ section >
</ div >
</ section >
2026-08-03 12:36:16 +08:00
</ div >
2026-08-03 15:35:31 +08:00
< section class = "card record-card" aria-labelledby = "record-title" >
< div class = "card-header" >
< div >< h2 id = "record-title" > 执行记录</ h2 >< p >< span id = "record-count" > 0</ span > 条假记录 · 按采集时间倒序</ p ></ div >
< div class = "record-actions" >< button type = "button" id = "view-record" disabled > 查看所选记录</ button ></ div >
</ div >
< div class = "table-wrap" >
< table class = "record-table" aria-describedby = "record-help" >
< thead >< tr >< th scope = "col" > 标题</ th >< th scope = "col" > 状态</ th ></ tr ></ thead >
< tbody id = "record-rows" ></ tbody >
</ table >
< div class = "table-empty" id = "record-empty" hidden > 暂无执行记录。开始轮询并产生结果后会显示在这里。</ div >
</ div >
2026-08-03 15:55:11 +08:00
< p class = "table-help" id = "record-help" > 单击选择;双击行、按 Enter 或使用“查看所选记录”在左侧打开详情。详情态可连续选择其他记录。</ p >
2026-08-03 12:36:16 +08:00
</ section >
</ div >
</ main >
2026-08-03 15:35:31 +08:00
< div class = "context-menu" id = "record-menu" role = "menu" hidden >
< button type = "button" id = "record-menu-open" role = "menuitem" > 查看记录详情</ button >
</ div >
2026-08-03 12:36:16 +08:00
< dialog id = "info-dialog" aria-labelledby = "dialog-title" >
< div class = "dialog-body" >
< h2 id = "dialog-title" > 安全说明</ h2 >
< p id = "dialog-text" ></ p >
< p >< strong > 原型边界:</ strong > 本对话框不会调用 API、ADB 或任何平台动作。</ p >
</ div >
< div class = "dialog-actions" >< button type = "button" class = "primary" id = "dialog-close" > 知道了</ button ></ div >
</ dialog >
< div id = "announcer" class = "sr-only" aria-live = "assertive" aria-atomic = "true" ></ div >
< script >
(() => {
'use strict' ;
const $ = ( id ) => document . getElementById ( id );
const task = {
id : 'CB-20260803-0042' ,
product : '纯棉圆领短袖 T 恤(原型假数据)' ,
2026-08-04 16:25:34 +08:00
color : '白色' , sizeQty : 'XL / 2 件' , gate1Price : 'CNY 32.50' , cap : 'CNY 80.00'
2026-08-03 12:36:16 +08:00
};
2026-08-03 15:35:31 +08:00
const records = [
{
id : 'REC-DEMO-0042' , taskId : 'CB-20260803-0042' , title : '纯棉圆领短袖 T 恤(原型假数据)' , collectedAt : '2026-08-03T13:52:18+08:00' ,
status : 'RECONCILIATION_REQUIRED' , spec : '白色 | XL | 2 件' , cap : 'CNY 80.00' , submission : 'SUB-DEMO-0042 · 结果不明确' ,
2026-08-04 16:25:34 +08:00
note : '围栏后网络中断,订单可能已创建。只允许核查同一提交记录,不能重试。' , evidence : [ '商品页原始截图(内联假证据)' , '规格面板原始截图(内联假证据)' ],
timeline : [ '13:51:04 同一趟精确选择规格' , '13:51:41 三道价格闸门通过(假数据)' , '13:51:58 围栏已建立' , '13:52:18 点击结果不明确,转人工核查' ],
2026-08-03 15:35:31 +08:00
result : '下单待核查:白色 | XL | CNY 32.50 | 2 件' , resultNote : '订单可能已创建、支付未完成。系统不会再次提交,也不会付款。'
},
{
id : 'REC-DEMO-0041' , taskId : 'CB-20260803-0041' , title : '办公室桌面收纳盒(原型假数据)' , collectedAt : '2026-08-03T12:24:09+08:00' ,
2026-08-04 16:25:34 +08:00
status : 'ORDERING' , spec : '透明 | L | 2 件' , cap : 'CNY 60.00' , submission : '未建立' ,
note : '管理员已开始采购,当前单趟已完成规格与闸门一,正在设置并复核数量。' , evidence : [ '商品页截图(内联假证据)' , '规格面板原始截图(内联假证据)' ],
timeline : [ '12:22:31 领取一次性授权' , '12:23:04 精确匹配透明 / L' , '12:23:38 闸门一读取 CNY 24.00' , '12:24:09 正在复核数量与闸门二' ],
result : '执行中:透明 | L | CNY 24.00 | 2 件' , resultNote : '无需等待管理员中途确认;任一门禁失败即停止。'
2026-08-03 15:35:31 +08:00
},
{
id : 'REC-DEMO-0038' , taskId : 'CB-20260803-0038' , title : '标签打印纸补货(原型假数据)' , collectedAt : '2026-08-03T10:06:42+08:00' ,
status : 'WAITING_PAYMENT' , spec : '白色 | 50× 30 | 1 件' , cap : 'CNY 50.00' , submission : 'SUB-DEMO-0038 · SUBMITTED' ,
2026-08-04 16:25:34 +08:00
note : '已明确创建待付款订单,自动化已停止,等待人核对后自行付款。' , evidence : [ '订单确认页内部原始截图(内联假证据)' ],
timeline : [ '10:04:22 同一趟精确选择规格' , '10:05:10 两次价格与数量复核通过' , '10:05:36 获得唯一围栏许可' , '10:06:42 明确创建待付款订单' ],
2026-08-03 15:35:31 +08:00
result : '已创建待付款订单:白色 | 50× 30 | CNY 46.00 | 1 件' , resultNote : '系统只创建待付款订单,付款始终由人完成。'
},
{
id : 'REC-DEMO-0032' , taskId : 'CB-20260803-0032' , title : '仓库打包胶带(原型假数据)' , collectedAt : '2026-08-03T09:18:05+08:00' ,
status : 'NEEDS_MANUAL' , spec : '透明 | 45mm | 4 件' , cap : 'CNY 55.00' , submission : '未建立' ,
2026-08-04 16:25:34 +08:00
note : '设置数量后规格面板价格与闸门一不一致,已在围栏前转人工。' , evidence : [],
2026-08-03 15:35:31 +08:00
timeline : [ '09:16:40 打开商品' , '09:17:22 匹配透明 / 45mm' , '09:18:05 价格闸门二不通过,停止自动流程' ],
2026-08-04 16:25:34 +08:00
result : '未采购:闸门一 CNY 12.00 | 闸门二 CNY 13.50' , resultNote : '没有建立围栏、创建订单或付款。'
2026-08-03 15:35:31 +08:00
}
]. sort (( a , b ) => b . collectedAt . localeCompare ( a . collectedAt ));
const recordStatus = {
RECONCILIATION_REQUIRED : { label : '结果待核查' , tone : 'critical' },
2026-08-04 16:25:34 +08:00
ORDERING : { label : '采购执行中' , tone : 'info' },
2026-08-03 15:35:31 +08:00
WAITING_PAYMENT : { label : '待付款' , tone : 'success' },
NEEDS_MANUAL : { label : '转人工' , tone : 'caution' }
};
let currentRecordId = records [ 0 ] ? . id || null ;
let recordFocusReturn = null ;
2026-08-03 15:55:11 +08:00
let workspaceMode = 'live' ;
let detailRequestId = 0 ;
2026-08-03 12:36:16 +08:00
const baseSteps = {
idle : [],
2026-08-04 16:25:34 +08:00
single : [ '核对一次性授权并打开商品' , '精确选择颜色分类与尺码' , '闸门一:读取 SKU 单价并校验上限' , '设置数量、读回并执行闸门二' , '确认页执行闸门三与控件唯一校验' , '申请服务端提交围栏' , '首次许可后持久化并内部点击一次' , '调和同一提交结果' ]
2026-08-03 12:36:16 +08:00
};
const scenarios = {
not_ready : { tone : 'critical' , title : 'web 服务不可达' , text : '未开始轮询。检查本地网络和服务地址;已完成的本地证据会保留待补传。' , web : 'bad' , device : 'ok' , version : 'ok' , leg : '未就绪' , legTone : 'critical' , action : '开始轮询' , enabled : false , reason : 'web 服务未就绪,不能领取任务。' , session : '未启动' , step : '连接检查' , kind : 'idle' , active :- 1 , countdown : '—' , fail : '1 / 3' },
version_mismatch : { tone : 'critical' , title : '拼多多版本与证据不一致' , text : '已 fail closed:停止领取真机任务。下一步是由人重新取证并记录 App 版本,不能沿用旧判据。' , web : 'ok' , device : 'ok' , version : 'bad' , leg : '版本失配' , legTone : 'critical' , action : '开始轮询' , enabled : false , reason : 'App 版本失配;必须先完成本项目真机取证。' , session : '已停止' , step : '等待重新取证' , kind : 'idle' , active :- 1 , countdown : '—' , fail : '0 / 3' },
idle : { tone : 'success' , title : '设备已就绪(模拟)' , text : '三项就绪条件在本场景中均为模拟通过,可以演示定时轮询;不会连接真实设备。' , web : 'ok' , device : 'ok' , version : 'ok' , leg : '空闲' , legTone : 'success' , action : '开始轮询' , enabled : true , reason : '点击只切换原型状态,不发起网络请求。' , session : '未启动' , step : '等待开始' , kind : 'idle' , active :- 1 , countdown : '—' , fail : '0 / 3' },
polling : { tone : 'info' , title : '正在轮询,暂无待领任务' , text : '无任务是正常空状态,不计入失败;将按配置间隔继续检查。' , web : 'ok' , device : 'ok' , version : 'ok' , leg : '轮询中' , legTone : 'info' , action : '停止轮询' , enabled : true , reason : '停止后不再领取新任务。' , session : '运行中' , step : '等待下一轮' , kind : 'idle' , active :- 1 , countdown : '00:12' , fail : '0 / 3' , count : 2 },
2026-08-04 16:25:34 +08:00
claimed : { tone : 'info' , title : '已领取管理员授权的采购任务' , text : '任务字段与最高总价已锁定;当前只准备打开目标商品,尚未建立提交围栏。' , web : 'ok' , device : 'ok' , version : 'ok' , leg : '单趟 · 已领取' , legTone : 'info' , action : '真机步骤执行中' , enabled : false , reason : '当前任务已开始,停止轮询只应阻止下一次领取,不能硬取消本任务。' , session : '运行中' , step : '核对授权与商品' , kind : 'single' , active : 0 , countdown : '暂停' , fail : '0 / 3' , lease : '04:48' },
selecting : { tone : 'info' , title : '正在精确选择颜色与尺码' , text : '只在已取证维度容器内精确唯一匹配并读回;缺失、重复或版本变化立即停止。' , web : 'ok' , device : 'ok' , version : 'ok' , leg : '单趟 · 选择规格' , legTone : 'info' , action : '真机步骤执行中' , enabled : false , reason : '当前受控步骤不能被硬取消。' , session : '运行中' , step : '精确选择规格' , kind : 'single' , active : 1 , countdown : '暂停' , fail : '0 / 3' , lease : '04:18' },
gate1 : { tone : 'info' , title : '闸门一:读取 SKU 单价' , text : '当前规格单价必须唯一可读,单价乘数量不得超过管理员授权的最高总价。' , web : 'ok' , device : 'ok' , version : 'ok' , leg : '单趟 · 闸门一' , legTone : 'info' , action : '安全校验进行中' , enabled : false , reason : '价格不可读或超上限时立即停止。' , session : '运行中' , step : '闸门一:读取 SKU 单价' , kind : 'single' , active : 2 , countdown : '暂停' , fail : '0 / 3' , lease : '03:58' },
quantity_gate2 : { tone : 'info' , title : '数量复核与闸门二' , text : '数量必须读回为 2;目标规格不能漂移,第二次单价必须等于闸门一 CNY 32.50。' , web : 'ok' , device : 'ok' , version : 'ok' , leg : '单趟 · 闸门二' , legTone : 'info' , action : '安全校验进行中' , enabled : false , reason : '数量、规格或价格变化都停止。' , session : '运行中' , step : '数量读回与闸门二' , kind : 'single' , active : 3 , countdown : '暂停' , fail : '0 / 3' , lease : '03:31' },
confirm_gate3 : { tone : 'caution' , title : '确认页与闸门三' , text : '正在核对规格、数量、应付总额与提交控件唯一;此时没有点击许可。' , web : 'ok' , device : 'ok' , version : 'ok' , leg : '单趟 · 闸门三' , legTone : 'caution' , action : '安全校验进行中' , enabled : false , reason : '没有服务端围栏许可,绝不能点击提交订单。' , session : '运行中' , step : '闸门三:核对应付总额' , kind : 'single' , active : 4 , countdown : '暂停' , fail : '0 / 3' , lease : '02:58' },
fencing : { tone : 'caution' , title : '正在申请服务端提交围栏' , text : '等待首次明确的 click_permitted。超时、冲突、网络失败或响应不明都不会点击。' , web : 'ok' , device : 'ok' , version : 'ok' , leg : '单趟 · 申请围栏' , legTone : 'caution' , action : '等待围栏响应' , enabled : false , reason : '申请中禁止取消、关闭或换 key 重复申请。' , session : '运行中' , step : '申请唯一提交围栏' , kind : 'single' , active : 5 , countdown : '暂停' , fail : '0 / 3' , lease : '02:31' },
fence_failed : { tone : 'critical' , title : '未获得首次明确点击许可' , text : '围栏失败或响应不明:没有点击提交订单。只核查同一幂等键,不能直接重试。' , web : 'bad' , device : 'ok' , version : 'ok' , leg : '单趟 · 未获许可' , legTone : 'critical' , action : '打开围栏核查说明' , enabled : true , safeAction : 'review' , reason : '只核查围栏状态,不触发提交。' , session : '已停止' , step : '核查幂等键' , kind : 'single' , active : 5 , countdown : '—' , fail : '1 / 3' , submission : '围栏状态未知 · 幂等键 DEMO-FENCE-042' },
fenced : { tone : 'caution' , title : '围栏已建立,只核对同一提交' , text : 'submission_id 已固定。内部流程至多点击一次;界面不提供提交、重领、放弃或重试。' , web : 'ok' , device : 'ok' , version : 'ok' , leg : '单趟 · 已围栏' , legTone : 'caution' , action : '打开同一提交核查说明' , enabled : true , safeAction : 'review' , reason : '只能查看与调和同一提交记录。' , session : '受控执行' , step : '调和订单结果' , kind : 'single' , active : 7 , countdown : '暂停' , fail : '0 / 3' , lease : '01:49' , submission : 'SUB-DEMO-0042 · FENCED' },
waiting_payment : { tone : 'success' , title : '已明确创建待付款订单' , text : '自动化已停止。系统不会付款;请人在拼多多核对后自行付款,再到采购服务标记。' , web : 'ok' , device : 'ok' , version : 'ok' , leg : 'WAITING_PAYMENT' , legTone : 'success' , action : '查看待付款说明' , enabled : true , safeAction : 'paymentInfo' , reason : '仅显示人工收口说明,不执行支付。' , session : '已停止' , step : '等待人工付款' , kind : 'single' , active : 8 , countdown : '—' , fail : '0 / 3' , count : 5 , submission : 'SUB-DEMO-0042 · SUBMITTED' },
uncertain : { tone : 'critical' , title : '订单可能已创建,结果不明确' , text : '保持金额预留和永久围栏,只核查 SUB-DEMO-0042;禁止重新领取、释放授权或再次点击。' , web : 'ok' , device : 'ok' , version : 'ok' , leg : 'RECONCILIATION_REQUIRED' , legTone : 'critical' , action : '打开人工核查说明' , enabled : true , safeAction : 'review' , reason : '核查不会发起新的下单。可另行导出假证据摘要。' , session : '已停止' , step : '人工核查同一提交' , kind : 'single' , active : 7 , countdown : '—' , fail : '0 / 3' , submission : 'SUB-DEMO-0042 · 结果不明确' },
security : { tone : 'critical' , title : '检测到安全校验,已停止' , text : '不绕过验证码、风控、人脸或短信。若已围栏,只调和同一提交;围栏前转人工。' , web : 'ok' , device : 'ok' , version : 'ok' , leg : '待人工 · 安全校验' , legTone : 'critical' , action : '打开人工核查说明' , enabled : true , safeAction : 'review' , reason : '只显示核查说明,不提供绕过或继续。' , session : '已停止' , step : '等待人工处理' , kind : 'single' , active : 7 , countdown : '—' , fail : '1 / 3' , submission : 'SUB-DEMO-0042 · RECONCILIATION_REQUIRED' },
payment_handoff : { tone : 'critical' , title : '进入外部支付交接,已停止' , text : '订单可能已创建、支付未完成。不会读取或输入支付凭据,也没有“继续付款”按钮。' , web : 'ok' , device : 'ok' , version : 'ok' , leg : '待人工 · 外部支付' , legTone : 'critical' , action : '打开人工核查说明' , enabled : true , safeAction : 'paymentInfo' , reason : '只显示人工核查与付款边界。' , session : '已停止' , step : '人工核查同一提交' , kind : 'single' , active : 7 , countdown : '—' , fail : '0 / 3' , submission : 'SUB-DEMO-0042 · HANDED_OFF' },
manual : { tone : 'caution' , title : '任务已转人工:闸门二价格变化' , text : '闸门一 CNY 32.50,数量设置后重读 CNY 35.00。围栏未建立,没有创建订单;需管理员核查后重置。' , web : 'ok' , device : 'ok' , version : 'ok' , leg : 'NEEDS_MANUAL' , legTone : 'caution' , action : '导出假诊断摘要' , enabled : true , safeAction : 'export' , reason : '导出只生成本地假文本,不含完整 XML、页面全文或敏感字段。' , session : '继续轮询其他任务' , step : '当前任务已移交' , kind : 'single' , active : 3 , countdown : '00:11' , fail : '0 / 3' , submission : '未建立' },
2026-08-03 15:35:31 +08:00
failed_stop : { tone : 'critical' , title : '连续失败达到阈值,轮询已停止' , text : '最近三次均为服务连接失败。检查设置和网络;修复后重新开始普通轮询,不会重试任何提交点击。' , web : 'bad' , device : 'ok' , version : 'ok' , leg : '自动停止' , legTone : 'critical' , action : '前往配置' , enabled : true , safeAction : 'settings' , reason : '先修复连接;不会自动恢复轮询。' , session : '已停止' , step : '等待修复' , kind : 'idle' , active :- 1 , countdown : '—' , fail : '3 / 3' , count : 4 }
};
const sessionControls = {
not_ready : { label : '开始轮询' , enabled : false , reason : 'web 服务未就绪,不能领取任务。' },
version_mismatch : { label : '开始轮询' , enabled : false , reason : 'App 版本失配,必须先完成本项目真机取证。' },
2026-08-04 16:25:34 +08:00
idle : { label : '开始轮询' , enabled : true , reason : '只启动领取会话;只有已获管理员开始采购授权的任务可执行。' },
2026-08-03 15:35:31 +08:00
polling : { label : '停止轮询' , enabled : true , reason : '停止后不再领取新任务。' },
2026-08-04 16:25:34 +08:00
claimed : { label : '停止轮询' , enabled : false , reason : '当前任务已领取;停止只应阻止下次领取,不能硬取消本任务。' },
selecting : { label : '停止轮询' , enabled : false , reason : '受控真机步骤执行中,禁止硬停止。' },
gate1 : { label : '停止轮询' , enabled : false , reason : '价格闸门执行中,禁止硬停止。' },
quantity_gate2 : { label : '停止轮询' , enabled : false , reason : '数量与闸门二执行中,禁止硬停止。' },
confirm_gate3 : { label : '停止轮询' , enabled : false , reason : '确认页只允许安全校验,禁止硬停止或额外点击。' },
2026-08-03 15:35:31 +08:00
fencing : { label : '停止轮询' , enabled : false , reason : '围栏申请中,禁止关闭、取消或重复申请。' },
fence_failed : { label : '开始轮询' , enabled : false , reason : '先核查围栏状态;不能用重新轮询绕过不明确结果。' },
fenced : { label : '停止轮询' , enabled : false , reason : '已建立围栏,只能调和同一提交。' },
waiting_payment : { label : '开始轮询' , enabled : false , reason : '先由人核对待付款订单并完成收口。' },
uncertain : { label : '开始轮询' , enabled : false , reason : '订单结果不明确,只能核查同一提交。' },
security : { label : '开始轮询' , enabled : false , reason : '安全校验已转人工,不允许自动继续。' },
payment_handoff : { label : '开始轮询' , enabled : false , reason : '外部支付交接中,自动化保持停止。' },
manual : { label : '停止轮询' , enabled : true , reason : '当前任务已移交,仍可安全停止普通轮询。' },
failed_stop : { label : '开始轮询' , enabled : false , reason : '先到配置页修复连接;不会自动恢复轮询。' }
2026-08-03 12:36:16 +08:00
};
function setHealth ( id , state , detail ) {
const el = $ ( id );
el . className = `health tone- ${ state === 'ok' ? 'success' : state === 'bad' ? 'critical' : 'caution' } ` ;
if ( detail ) el . querySelector ( 'span:last-child' ). textContent = detail ;
}
function renderSteps ( kind , active ) {
const items = baseSteps [ kind ] || [];
$ ( 'steps' ). replaceChildren (... items . map (( label , index ) => {
const li = document . createElement ( 'li' );
if ( active >= items . length || index < active ) li . className = 'done' ;
else if ( index === active ) li . className = 'active' ;
const mark = document . createElement ( 'span' ); mark . className = 'step-mark' ; mark . textContent = index < active || active >= items . length ? '✓' : String ( index + 1 );
const text = document . createElement ( 'span' ); text . textContent = label ;
const state = document . createElement ( 'small' ); state . textContent = index < active || active >= items . length ? '已完成' : index === active ? '当前' : '等待' ;
li . append ( mark , text , state ); return li ;
}));
}
function appendLog ( level , message ) {
const li = document . createElement ( 'li' );
const now = new Date ();
const time = document . createElement ( 'time' ); time . textContent = now . toLocaleTimeString ( 'zh-CN' , { hour12 : false });
const badge = document . createElement ( 'span' ); badge . className = `log-level tone- ${ level === '停止' ? 'critical' : level === '注意' ? 'caution' : level === '完成' ? 'success' : 'info' } ` ; badge . textContent = level ;
const text = document . createElement ( 'span' ); text . textContent = message ;
li . append ( time , badge , text ); $ ( 'logs' ). prepend ( li );
while ( $ ( 'logs' ). children . length > 8 ) $ ( 'logs' ). lastElementChild . remove ();
}
2026-08-03 15:35:31 +08:00
function formatRecordTime ( value ) {
return value . slice ( 5 , 16 ). replace ( 'T' , ' ' );
}
function selectRecord ( id , focus = false ) {
currentRecordId = id ;
2026-08-03 15:55:11 +08:00
let selectedRow = null ;
2026-08-03 15:35:31 +08:00
for ( const row of $ ( 'record-rows' ). rows ) {
const selected = row . dataset . recordId === id ;
row . setAttribute ( 'aria-selected' , String ( selected ));
row . tabIndex = selected ? 0 : - 1 ;
2026-08-03 15:55:11 +08:00
if ( selected ) selectedRow = row ;
2026-08-03 15:35:31 +08:00
if ( selected && focus ) row . focus ();
}
$ ( 'view-record' ). disabled = ! records . some (( record ) => record . id === id );
2026-08-03 15:55:11 +08:00
if ( workspaceMode === 'record' ) {
recordFocusReturn = selectedRow || recordFocusReturn ;
renderRecordDetail ( id );
}
2026-08-03 15:35:31 +08:00
}
function renderRecords () {
const rows = records . map (( record ) => {
const status = recordStatus [ record . status ] || { label : record . status , tone : 'info' };
const row = document . createElement ( 'tr' );
row . dataset . recordId = record . id ;
row . setAttribute ( 'aria-selected' , 'false' );
const titleCell = document . createElement ( 'td' );
const title = document . createElement ( 'span' ); title . className = 'record-title' ; title . textContent = record . title ;
const meta = document . createElement ( 'span' ); meta . className = 'record-meta' ; meta . textContent = ` ${ record . taskId } · ${ formatRecordTime ( record . collectedAt ) } ` ;
titleCell . append ( title , meta );
const statusCell = document . createElement ( 'td' );
const badge = document . createElement ( 'span' ); badge . className = 'record-status' ; badge . dataset . tone = status . tone ; badge . textContent = status . label ;
statusCell . append ( badge ); row . append ( titleCell , statusCell );
row . addEventListener ( 'click' , () => selectRecord ( record . id ));
row . addEventListener ( 'dblclick' , ( event ) => { if ( event . target . closest ( 'button,a,input,select' )) return ; selectRecord ( record . id ); openRecord ( record . id , row ); });
row . addEventListener ( 'keydown' , ( event ) => {
if ( event . key === 'Enter' ) { event . preventDefault (); openRecord ( record . id , row ); return ; }
if ( event . key === 'ArrowDown' || event . key === 'ArrowUp' ) {
event . preventDefault ();
const rows = [... $ ( 'record-rows' ). rows ]; const index = rows . indexOf ( row ); const delta = event . key === 'ArrowDown' ? 1 : - 1 ;
const next = rows [ Math . max ( 0 , Math . min ( rows . length - 1 , index + delta ))]; if ( next ) selectRecord ( next . dataset . recordId , true );
}
if ( event . key === 'F10' && event . shiftKey ) { event . preventDefault (); showRecordMenu ( record . id , row . getBoundingClientRect (). left + 16 , row . getBoundingClientRect (). top + 24 ); }
});
row . addEventListener ( 'contextmenu' , ( event ) => { event . preventDefault (); selectRecord ( record . id ); showRecordMenu ( record . id , event . clientX , event . clientY ); });
return row ;
});
$ ( 'record-rows' ). replaceChildren (... rows );
$ ( 'record-count' ). textContent = String ( records . length );
$ ( 'record-empty' ). hidden = records . length > 0 ;
if ( records . length ) selectRecord ( records . some (( record ) => record . id === currentRecordId ) ? currentRecordId : records [ 0 ]. id );
}
function showRecordMenu ( id , x , y ) {
currentRecordId = id ;
const menu = $ ( 'record-menu' ); menu . hidden = false ;
menu . style . left = ` ${ Math . min ( x , innerWidth - 170 ) } px` ; menu . style . top = ` ${ Math . min ( y , innerHeight - 54 ) } px` ;
$ ( 'record-menu-open' ). focus ();
}
function hideRecordMenu () { $ ( 'record-menu' ). hidden = true ; }
function createEvidence ( label ) {
const figure = document . createElement ( 'figure' ); figure . className = 'evidence-card' ;
const svg = $ ( 'product-preview-image' ). cloneNode ( true ); svg . hidden = false ; svg . removeAttribute ( 'id' ); svg . setAttribute ( 'aria-label' , ` ${ label } ,原型内联假证据` );
const caption = document . createElement ( 'figcaption' ); caption . textContent = ` ${ label } · 不访问真实拼多多` ;
figure . append ( svg , caption ); return figure ;
}
2026-08-03 15:55:11 +08:00
function showDetailFeedback ( title , text , retry = false ) {
$ ( 'detail-feedback-title' ). textContent = title ;
$ ( 'detail-feedback-text' ). textContent = text ;
$ ( 'retry-detail' ). hidden = ! retry ;
$ ( 'detail-feedback' ). hidden = false ;
$ ( 'detail-content' ). hidden = true ;
}
async function renderRecordDetail ( id ) {
const requestId = ++ detailRequestId ;
const prototypeState = $ ( 'detail-state' ). value ;
const record = records . find (( item ) => item . id === id );
$ ( 'record-detail-pane' ). setAttribute ( 'aria-busy' , String ( prototypeState === 'loading' || prototypeState === 'normal' ));
if ( prototypeState === 'loading' ) {
showDetailFeedback ( '正在读取执行记录' , `正在按稳定记录 ID ${ id } 读取文字、图片索引和采购结果。` );
return ;
}
if ( prototypeState === 'error' ) {
showDetailFeedback ( '执行记录加载失败' , '当前任务与右侧列表不受影响。可安全重试读取同一记录,不会重新执行采购。' , true );
$ ( 'record-detail-pane' ). setAttribute ( 'aria-busy' , 'false' );
return ;
}
if ( prototypeState === 'missing' || ! record ) {
showDetailFeedback ( '执行记录已不存在' , '记录可能已归档或当前账号无权查看。请返回当前任务;不会用其他记录补齐内容。' );
$ ( 'record-detail-pane' ). setAttribute ( 'aria-busy' , 'false' );
return ;
}
showDetailFeedback ( '正在读取执行记录' , `正在加载 ${ record . taskId } 的已保存证据。` );
await Promise . resolve ();
if ( requestId !== detailRequestId || workspaceMode !== 'record' || currentRecordId !== id ) return ;
2026-08-03 15:35:31 +08:00
const status = recordStatus [ record . status ] || { label : record . status };
2026-08-03 15:55:11 +08:00
$ ( 'record-detail-title' ). textContent = record . title ;
$ ( 'record-detail-meta' ). textContent = ` ${ record . taskId } · ${ formatRecordTime ( record . collectedAt ) } · ${ status . label } ` ;
2026-08-03 15:35:31 +08:00
$ ( 'detail-task-id' ). textContent = record . taskId ; $ ( 'detail-product' ). textContent = record . title ; $ ( 'detail-spec' ). textContent = record . spec ;
$ ( 'detail-cap' ). textContent = record . cap ; $ ( 'detail-submission' ). textContent = record . submission ; $ ( 'detail-note' ). textContent = record . note ;
$ ( 'detail-timeline' ). replaceChildren (... record . timeline . map (( value ) => { const li = document . createElement ( 'li' ); li . textContent = value ; return li ; }));
if ( record . evidence . length ) $ ( 'detail-evidence' ). replaceChildren (... record . evidence . map ( createEvidence ));
else { const empty = document . createElement ( 'div' ); empty . className = 'preview-empty' ; const strong = document . createElement ( 'strong' ); strong . textContent = '暂无可信图片' ; empty . append ( strong , '记录保留原始文字,不用其他图片凑合。' ); $ ( 'detail-evidence' ). replaceChildren ( empty ); }
$ ( 'detail-result' ). textContent = record . result ; $ ( 'detail-result-note' ). textContent = record . resultNote ;
2026-08-03 15:55:11 +08:00
$ ( 'detail-feedback' ). hidden = true ; $ ( 'detail-content' ). hidden = false ;
$ ( 'record-detail-pane' ). setAttribute ( 'aria-busy' , 'false' );
}
function openRecord ( id , source ) {
const record = records . find (( item ) => item . id === id ); if ( ! record ) return ;
hideRecordMenu (); currentRecordId = id ;
recordFocusReturn = source || [... $ ( 'record-rows' ). rows ]. find (( row ) => row . dataset . recordId === id ) || $ ( 'view-record' );
workspaceMode = 'record' ; $ ( 'workspace' ). dataset . mode = 'record' ;
$ ( 'live-workspace' ). hidden = true ; $ ( 'record-detail-pane' ). hidden = false ;
renderRecordDetail ( id );
$ ( 'return-live' ). focus ();
$ ( 'announcer' ). textContent = `已在左侧打开 ${ record . title } 的执行记录详情。按 Escape 或返回当前任务按钮返回。` ;
}
function showLiveWorkspace () {
if ( workspaceMode !== 'record' ) return ;
detailRequestId += 1 ; workspaceMode = 'live' ; $ ( 'workspace' ). dataset . mode = 'live' ;
$ ( 'record-detail-pane' ). hidden = true ; $ ( 'live-workspace' ). hidden = false ;
const currentRow = [... $ ( 'record-rows' ). rows ]. find (( row ) => row . dataset . recordId === currentRecordId );
const focusTarget = currentRow || ( recordFocusReturn ? . isConnected ? recordFocusReturn : $ ( 'view-record' ));
focusTarget ? . focus ();
$ ( 'announcer' ). textContent = '已返回当前任务;执行记录选择和列表位置已保留。' ;
2026-08-03 15:35:31 +08:00
}
2026-08-03 12:36:16 +08:00
function render ( key , announce = true ) {
const s = scenarios [ key ];
2026-08-03 15:35:31 +08:00
const sessionControl = sessionControls [ key ];
2026-08-03 12:36:16 +08:00
$ ( 'state-banner' ). dataset . tone = s . tone ;
$ ( 'banner-title' ). textContent = s . title ; $ ( 'banner-text' ). textContent = s . text ;
setHealth ( 'web-health' , s . web , s . web === 'bad' ? '不可达(模拟)· 已停止领取' : '已连接 · 假环境' );
setHealth ( 'device-health' , s . device , 'FAKE-DEVICE-01 · 已解锁(模拟)' );
setHealth ( 'version-health' , s . version , s . version === 'bad' ? '模拟失配 · 必须重新取证' : '模拟匹配 · 真实版本值待 T-103 取证' );
$ ( 'leg-badge' ). textContent = s . leg ; $ ( 'leg-badge' ). dataset . tone = s . legTone ;
const hasTask = ! [ 'not_ready' , 'version_mismatch' , 'idle' , 'polling' , 'failed_stop' ]. includes ( key );
$ ( 'task-subtitle' ). textContent = hasTask ? '本页所有任务字段均为假数据' : '尚未领取任务' ;
$ ( 'task-id' ). textContent = hasTask ? task . id : '—' ; $ ( 'lease' ). textContent = s . lease || '—' ;
$ ( 'product' ). textContent = hasTask ? task . product : '暂无商品;轮询会在安全就绪后领取一条任务' ;
$ ( 'color' ). textContent = hasTask ? task . color : '—' ; $ ( 'size-qty' ). textContent = hasTask ? task . sizeQty : '—' ;
2026-08-04 16:25:34 +08:00
$ ( 'gate1-price' ). textContent = hasTask && [ 'gate1' , 'quantity_gate2' , 'confirm_gate3' , 'fencing' , 'fence_failed' , 'fenced' , 'waiting_payment' , 'uncertain' , 'security' , 'payment_handoff' , 'manual' ]. includes ( $ ( 'scenario' ). value ) ? task . gate1Price : '—' ; $ ( 'price-cap' ). textContent = hasTask ? task . cap : '—' ;
2026-08-03 12:36:16 +08:00
$ ( 'submission' ). textContent = s . submission || '未建立' ;
2026-08-03 15:35:31 +08:00
$ ( 'preview-empty' ). hidden = hasTask ; $ ( 'product-preview-image' ). hidden = ! hasTask ;
2026-08-04 16:25:34 +08:00
$ ( 'preview-source' ). textContent = hasTask ? '来源:内部原始商品截图(内联假证据)' : '来源:尚无可信图片' ;
2026-08-03 15:35:31 +08:00
$ ( 'preview-time' ). textContent = hasTask ? '采集时间:2026-08-03 13:51(假数据)' : '采集时间:—' ;
2026-08-03 12:36:16 +08:00
renderSteps ( s . kind , s . active );
$ ( 'countdown' ). textContent = s . countdown ; $ ( 'failure-count' ). textContent = s . fail ; $ ( 'completed-count' ). textContent = String ( s . count || 0 );
$ ( 'session-state' ). textContent = s . session ; $ ( 'current-step' ). textContent = s . step ;
2026-08-03 15:55:11 +08:00
$ ( 'live-task-state' ). textContent = `实时: ${ s . session } · ${ s . step } ` ; $ ( 'live-task-state' ). dataset . tone = s . tone ;
2026-08-03 15:35:31 +08:00
$ ( 'primary-action' ). textContent = sessionControl . label ; $ ( 'primary-action' ). disabled = ! sessionControl . enabled ;
$ ( 'action-reason' ). textContent = sessionControl . reason ;
$ ( 'context-action' ). hidden = ! s . safeAction ; $ ( 'context-action' ). textContent = s . safeAction ? s . action : '' ;
$ ( 'context-action' ). dataset . safeAction = s . safeAction || '' ;
2026-08-03 12:36:16 +08:00
if ( announce ) { $ ( 'announcer' ). textContent = ` ${ s . title } 。 ${ s . text } ` ; appendLog ( s . tone === 'critical' ? '停止' : s . tone === 'caution' ? '注意' : s . tone === 'success' ? '完成' : '状态' , `切换原型状态: ${ s . title } ` ); }
}
function showDialog ( title , text ) {
$ ( 'dialog-title' ). textContent = title ; $ ( 'dialog-text' ). textContent = text ; $ ( 'info-dialog' ). showModal ();
}
function exportDemo () {
2026-08-03 15:35:31 +08:00
const blob = new Blob ([ '采购工具原型假诊断摘要\n无真实任务、无凭据、无页面全文。\n' ], { type : 'text/plain;charset=utf-8' });
const url = URL . createObjectURL ( blob ); const a = document . createElement ( 'a' ); a . href = url ; a . download = 'purchase-tool-prototype-diagnostic.txt' ; a . click (); URL . revokeObjectURL ( url );
2026-08-03 12:36:16 +08:00
appendLog ( '完成' , '已生成本地假诊断摘要;没有外部网络请求。' );
}
$ ( 'scenario' ). addEventListener ( 'change' , ( e ) => render ( e . target . value ));
$ ( 'theme' ). addEventListener ( 'change' , ( e ) => { document . documentElement . dataset . theme = e . target . value ; $ ( 'announcer' ). textContent = `主题已切换为 ${ e . target . options [ e . target . selectedIndex ]. text } ` ; });
2026-08-03 15:55:11 +08:00
$ ( 'reset' ). addEventListener ( 'click' , () => { showLiveWorkspace (); $ ( 'scenario' ). value = 'idle' ; $ ( 'detail-state' ). value = 'normal' ; $ ( 'logs' ). replaceChildren (); currentRecordId = records [ 0 ] ? . id || null ; renderRecords (); render ( 'idle' ); appendLog ( '状态' , '假数据已复位。' ); });
2026-08-03 12:36:16 +08:00
$ ( 'primary-action' ). addEventListener ( 'click' , () => {
2026-08-03 15:35:31 +08:00
const next = $ ( 'scenario' ). value === 'idle' ? 'polling' : 'idle' ; $ ( 'scenario' ). value = next ; render ( next );
});
$ ( 'context-action' ). addEventListener ( 'click' , () => {
const action = $ ( 'context-action' ). dataset . safeAction ;
2026-08-03 12:36:16 +08:00
if ( action === 'settings' ) { location . href = 'desk-device-settings.html' ; return ; }
if ( action === 'export' ) { exportDemo (); return ; }
if ( action === 'paymentInfo' ) { showDialog ( '人工付款边界' , '自动化已经停止。本系统不会点击支付、免密支付、先用后付或任何扣款控件;订单状态需要人核查。' ); return ; }
showDialog ( '只核查同一提交记录' , '围栏后只能围绕 SUB-DEMO-0042 调和。这里没有重新领取、放弃授权、重新申请围栏或再次提交的动作。' );
});
2026-08-03 15:35:31 +08:00
$ ( 'view-record' ). addEventListener ( 'click' , () => openRecord ( currentRecordId , [... $ ( 'record-rows' ). rows ]. find (( row ) => row . dataset . recordId === currentRecordId )));
$ ( 'record-menu-open' ). addEventListener ( 'click' , () => openRecord ( currentRecordId , [... $ ( 'record-rows' ). rows ]. find (( row ) => row . dataset . recordId === currentRecordId )));
2026-08-03 15:55:11 +08:00
$ ( 'return-live' ). addEventListener ( 'click' , showLiveWorkspace );
$ ( 'detail-state' ). addEventListener ( 'change' , () => { if ( workspaceMode === 'record' ) renderRecordDetail ( currentRecordId ); });
$ ( 'retry-detail' ). addEventListener ( 'click' , () => { $ ( 'detail-state' ). value = 'normal' ; renderRecordDetail ( currentRecordId ); });
2026-08-03 15:35:31 +08:00
document . addEventListener ( 'pointerdown' , ( event ) => { if ( ! $ ( 'record-menu' ). hidden && ! $ ( 'record-menu' ). contains ( event . target )) hideRecordMenu (); });
2026-08-03 15:55:11 +08:00
document . addEventListener ( 'keydown' , ( event ) => {
if ( event . key !== 'Escape' ) return ;
if ( ! $ ( 'record-menu' ). hidden ) { event . preventDefault (); hideRecordMenu (); return ; }
if ( $ ( 'info-dialog' ). open ) return ;
if ( workspaceMode === 'record' ) { event . preventDefault (); showLiveWorkspace (); }
});
2026-08-03 15:35:31 +08:00
window . addEventListener ( 'resize' , hideRecordMenu );
2026-08-03 12:36:16 +08:00
$ ( 'export-log' ). addEventListener ( 'click' , exportDemo );
$ ( 'close-demo' ). addEventListener ( 'click' , () => {
2026-08-04 16:25:34 +08:00
const unsafe = [ 'claimed' , 'selecting' , 'gate1' , 'quantity_gate2' , 'confirm_gate3' , 'fencing' , 'fenced' ]. includes ( $ ( 'scenario' ). value );
2026-08-03 12:36:16 +08:00
showDialog ( unsafe ? '当前不能直接关闭' : '关闭窗口协议' , unsafe ? '真机或围栏流程处于受控阶段。原生实现需先完成安全停靠或保持同一 submission 调和,不能让关闭看起来等于“可以重来”。' : '原生实现关闭窗口后立即停止普通轮询,不留后台进程。此 HTML 只演示规则,不会关闭浏览器。' );
});
$ ( 'dialog-close' ). addEventListener ( 'click' , () => $ ( 'info-dialog' ). close ());
$ ( 'info-dialog' ). addEventListener ( 'click' , ( e ) => { if ( e . target === $ ( 'info-dialog' )) $ ( 'info-dialog' ). close (); });
2026-08-03 15:35:31 +08:00
renderRecords (); render ( 'idle' , false ); appendLog ( '状态' , '原型已加载;未连接任何服务或设备。' );
2026-08-03 12:36:16 +08:00
})();
</ script >
</ body >
</ html >