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 ;
grid-template-columns : minmax ( 240 px , 1 fr ) auto ;
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 ; }
. stack { display : grid ; grid-template-rows : auto minmax ( 240 px , 1 fr ); gap : 12 px ; min-width : 0 ; }
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:35:31 +08:00
. record-dialog { width : min ( 940 px , calc ( 100 vw - 32 px )); }
. record-dialog header { display : flex ; justify-content : space-between ; gap : 12 px ; padding : 16 px 20 px 12 px ; border-bottom : 1 px solid var ( -- divider ); }
. record-dialog header h2 { margin : 0 ; font-size : 18 px ; }
. record-dialog header p { margin : 3 px 0 0 ; color : var ( -- text - secondary ); font-size : 12 px ; }
. record-dialog-grid { display : grid ; grid-template-columns : minmax ( 0 , 3 fr ) minmax ( 260 px , 2 fr ); gap : 16 px ; padding : 16 px 20 px ; }
. 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 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:35:31 +08:00
. record-dialog-grid { grid-template-columns : 1 fr ; }
. 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 >
< option value = "trial" > 执行中 · 第一趟试选</ option >
< option value = "trial_done" > 第一趟完成 · 已释放手机</ option >
< option value = "dry_run" > 执行中 · 下单只读演练</ option >
< option value = "dry_run_ready" > 演练完成 · 等待真实第二趟</ option >
< option value = "order_prefence" > 真实第二趟 · 围栏前校验</ option >
< option value = "fencing" > 真实第二趟 · 申请围栏中</ option >
< 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 >
< 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 >
< div class = "workspace" >
< div class = "stack" >
< 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 >
< div >< dt > 授权单价</ dt >< dd id = "auth-price" > —</ dd ></ div >
< 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 >
</ 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 >
< 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 >
< dialog class = "record-dialog" id = "record-dialog" aria-labelledby = "record-dialog-title" >
< header >
< div >< h2 id = "record-dialog-title" > 执行记录详情</ h2 >< p id = "record-dialog-meta" ></ p ></ div >
< button type = "button" id = "record-dialog-close" aria-label = "关闭执行记录详情" > 关闭</ button >
</ header >
< div class = "record-dialog-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 class = "dialog-actions" >< button type = "button" class = "primary" id = "record-dialog-done" > 关闭详情</ button ></ div >
</ dialog >
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 恤(原型假数据)' ,
color : '白色' , sizeQty : 'XL / 2 件' , authPrice : 'CNY 32.50' , cap : 'CNY 80.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 · 结果不明确' ,
note : '围栏后网络中断,订单可能已创建。只允许核查同一提交记录,不能重试。' , evidence : [ '商品页截图裁剪(内联假证据)' , '规格面板截图(内联假证据)' ],
timeline : [ '13:51:04 重新选择同一规格' , '13:51:41 三道价格闸门通过(假数据)' , '13:51:58 围栏已建立' , '13:52:18 点击结果不明确,转人工核查' ],
result : '下单待核查:白色 | XL | CNY 32.50 | 2 件' , resultNote : '订单可能已创建、支付未完成。系统不会再次提交,也不会付款。'
},
{
id : 'REC-DEMO-0041' , taskId : 'CB-20260803-0041' , title : '办公室桌面收纳盒(原型假数据)' , collectedAt : '2026-08-03T12:24:09+08:00' ,
status : 'WAITING_CONFIRMATION' , spec : '透明 | L | 2 件' , cap : 'CNY 60.00' , submission : '未建立' ,
note : '第一趟试选完成,规格面板证据已回传,等待管理员确认。' , evidence : [ '商品页截图裁剪(内联假证据)' , '规格面板截图(内联假证据)' ],
timeline : [ '12:22:31 打开商品' , '12:23:04 匹配透明 / L' , '12:23:38 读取规格面板单价' , '12:24:09 截图并退出商品页' ],
result : '试选:透明 | L | CNY 24.00 | 2 件' , resultNote : '这只是试选结果,不代表已下单。'
},
{
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' ,
note : '已明确创建待付款订单,自动化已停止,等待人核对后自行付款。' , evidence : [ '订单确认页非敏感截图(内联假证据)' ],
timeline : [ '10:04:22 重新选择同一规格' , '10:05:10 价格与数量复核通过' , '10:05:36 获得唯一围栏许可' , '10:06:42 明确创建待付款订单' ],
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 : '未建立' ,
note : '商品页面未取得可信图片,授权价与当前规格面板价格不一致,已转人工。' , evidence : [],
timeline : [ '09:16:40 打开商品' , '09:17:22 匹配透明 / 45mm' , '09:18:05 价格闸门二不通过,停止自动流程' ],
result : '未采购:授权价 CNY 12.00 | 当前价 CNY 13.50' , resultNote : '没有创建订单,也没有付款。'
}
]. sort (( a , b ) => b . collectedAt . localeCompare ( a . collectedAt ));
const recordStatus = {
RECONCILIATION_REQUIRED : { label : '结果待核查' , tone : 'critical' },
WAITING_CONFIRMATION : { label : '等待确认' , tone : 'caution' },
WAITING_PAYMENT : { label : '待付款' , tone : 'success' },
NEEDS_MANUAL : { label : '转人工' , tone : 'caution' }
};
let currentRecordId = records [ 0 ] ? . id || null ;
let recordFocusReturn = null ;
2026-08-03 12:36:16 +08:00
const baseSteps = {
idle : [],
trial : [ '打开商品(待真机判据)' , '精确匹配颜色分类与尺码' , '从规格面板读取单价' , '截图并退出商品页' ],
dry : [ '重新打开商品(待真机判据)' , '精确选择与数量复核' , '读取订单确认页非敏感摘要' , '验证提交控件唯一后退出' ],
order : [ '重新选择同一规格' , '闸门二:单价必须等于授权价' , '数量复核与闸门三' , '申请服务端提交围栏' , '围栏许可后内部仅点击一次' , '调和同一提交结果' ]
};
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 },
trial : { tone : 'info' , title : '第一趟试选正在执行' , text : '只勾选规格、读价和截图;不会进入订单确认流程,完成后必须退出商品页释放手机。' , web : 'ok' , device : 'ok' , version : 'ok' , leg : 'TRIAL · 第一趟试选' , legTone : 'info' , action : '真机步骤执行中' , enabled : false , reason : '真机步骤期间禁用硬取消和关闭,避免留下无法判定的中间态。' , session : '运行中' , step : '读取规格面板单价' , kind : 'trial' , active : 2 , countdown : '暂停' , fail : '0 / 3' , lease : '04:18' },
trial_done : { tone : 'success' , title : '试选已回传,手机已释放' , text : '规格与价格证据已保存(模拟);任务等待管理员确认,本设备继续下一轮。' , web : 'ok' , device : 'ok' , version : 'ok' , leg : 'TRIAL · 已完成' , legTone : 'success' , action : '停止轮询' , enabled : true , reason : '手机已退出商品页,可以安全停止轮询。' , session : '运行中' , step : '等待下一轮' , kind : 'trial' , active : 4 , countdown : '00:09' , fail : '0 / 3' , count : 3 },
dry_run : { tone : 'info' , title : 'DRY-RUN · 只读演练' , text : '只到订单确认页读取非敏感摘要并验证控件唯一;不会提交订单,也不会消费授权。' , web : 'ok' , device : 'ok' , version : 'ok' , leg : 'DRY-RUN · 不会提交' , legTone : 'info' , action : '只读演练进行中' , enabled : false , reason : '演练中不提供提交动作;真机步骤期间受控关闭。' , session : '运行中' , step : '读取订单确认页' , kind : 'dry' , active : 2 , countdown : '暂停' , fail : '0 / 3' , lease : '03:42' },
dry_run_ready : { tone : 'success' , title : '只读演练已完成' , text : '演练证据已回传(模拟)并退出确认页。旧演练不能替代真实第二趟的当前页面事实。' , web : 'ok' , device : 'ok' , version : 'ok' , leg : 'DRY-RUN · READY' , legTone : 'success' , action : '停止轮询' , enabled : true , reason : '等待服务端安排真实第二趟;不会由用户直接触发提交。' , session : '运行中' , step : '等待真实第二趟' , kind : 'dry' , active : 4 , countdown : '00:16' , fail : '0 / 3' , count : 4 },
order_prefence : { tone : 'caution' , title : '真实第二趟 · 围栏前校验' , text : '正在重新验证授权、闸门二、数量、闸门三和控件唯一。此时尚无点击许可。' , web : 'ok' , device : 'ok' , version : 'ok' , leg : 'ORDER · 围栏前' , legTone : 'caution' , action : '安全校验进行中' , enabled : false , reason : '没有服务端围栏许可,绝不能点击提交订单。' , session : '运行中' , step : '闸门三:核对实付款' , kind : 'order' , active : 2 , countdown : '暂停' , fail : '0 / 3' , lease : '02:58' },
fencing : { tone : 'caution' , title : '正在申请服务端提交围栏' , text : '等待明确的 click_permitted。超时、冲突、网络失败或响应不明都不会点击。' , web : 'ok' , device : 'ok' , version : 'ok' , leg : 'ORDER · 申请围栏' , legTone : 'caution' , action : '等待围栏响应' , enabled : false , reason : '申请中禁止取消、关闭或重复申请。' , session : '运行中' , step : '申请唯一提交围栏' , kind : 'order' , active : 3 , countdown : '暂停' , fail : '0 / 3' , lease : '02:31' },
fence_failed : { tone : 'critical' , title : '未获得点击许可' , text : '围栏申请失败或响应不明:没有点击提交订单。请按幂等键核查服务端记录,不能直接重试。' , web : 'bad' , device : 'ok' , version : 'ok' , leg : 'ORDER · 未获许可' , legTone : 'critical' , action : '打开围栏核查说明' , enabled : true , safeAction : 'review' , reason : '只核查围栏状态,不会触发提交。' , session : '已停止' , step : '核查幂等键' , kind : 'order' , active : 3 , countdown : '—' , fail : '1 / 3' , submission : '围栏状态未知 · 幂等键 DEMO-FENCE-042' },
fenced : { tone : 'caution' , title : '围栏已建立,只核对同一提交' , text : 'submission_id 已固定。内部流程至多点击一次;用户界面不提供提交、重领、放弃或重试入口。' , web : 'ok' , device : 'ok' , version : 'ok' , leg : 'ORDER · 已围栏' , legTone : 'caution' , action : '打开同一提交核查说明' , enabled : true , safeAction : 'review' , reason : '只能查看与调和同一提交记录。' , session : '受控执行' , step : '调和订单结果' , kind : 'order' , active : 5 , countdown : '暂停' , fail : '0 / 3' , lease : '01:49' , submission : 'SUB-DEMO-0042 · FENCED' },
waiting_payment : { tone : 'success' , title : '已明确创建待付款订单' , text : '自动化已停止。系统不会付款;请人在拼多多核对后自行付款,再到 web 端标记。' , web : 'ok' , device : 'ok' , version : 'ok' , leg : 'WAITING_PAYMENT' , legTone : 'success' , action : '查看待付款说明' , enabled : true , safeAction : 'paymentInfo' , reason : '仅显示人工收口说明,不执行支付。' , session : '已停止' , step : '等待人工付款' , kind : 'order' , active : 6 , 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 : 'order' , active : 5 , 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 : 'order' , active : 5 , 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 : 'order' , active : 5 , 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 : '导出仅生成本地假文本,不包含页面全文或敏感信息。' , session : '继续轮询其他任务' , step : '当前任务已移交' , kind : 'order' , active : 1 , 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 版本失配,必须先完成本项目真机取证。' },
idle : { label : '开始轮询' , enabled : true , reason : '只启动普通轮询,不会直接执行下单。' },
polling : { label : '停止轮询' , enabled : true , reason : '停止后不再领取新任务。' },
trial : { label : '停止轮询' , enabled : false , reason : '真机步骤执行中,禁止硬停止。' },
trial_done : { label : '停止轮询' , enabled : true , reason : '手机已退出商品页,可以安全停止轮询。' },
dry_run : { label : '停止轮询' , enabled : false , reason : '只读演练执行中,禁止硬停止。' },
dry_run_ready : { label : '停止轮询' , enabled : true , reason : '演练已退出确认页,可以安全停止轮询。' },
order_prefence : { label : '停止轮询' , enabled : false , reason : '真实第二趟校验中,禁止硬停止。' },
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 ;
for ( const row of $ ( 'record-rows' ). rows ) {
const selected = row . dataset . recordId === id ;
row . setAttribute ( 'aria-selected' , String ( selected ));
row . tabIndex = selected ? 0 : - 1 ;
if ( selected && focus ) row . focus ();
}
$ ( 'view-record' ). disabled = ! records . some (( record ) => record . id === id );
}
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 ;
}
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' );
const status = recordStatus [ record . status ] || { label : record . status };
$ ( 'record-dialog-title' ). textContent = record . title ;
$ ( 'record-dialog-meta' ). textContent = ` ${ record . taskId } · ${ formatRecordTime ( record . collectedAt ) } · ${ status . label } ` ;
$ ( '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 ;
$ ( 'record-dialog' ). showModal ();
}
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 : '—' ;
$ ( 'auth-price' ). textContent = hasTask && s . kind !== 'trial' ? task . authPrice : '—' ; $ ( 'price-cap' ). textContent = hasTask ? task . cap : '—' ;
$ ( 'submission' ). textContent = s . submission || '未建立' ;
2026-08-03 15:35:31 +08:00
$ ( 'preview-empty' ). hidden = hasTask ; $ ( 'product-preview-image' ). hidden = ! hasTask ;
$ ( 'preview-source' ). textContent = hasTask ? '来源:商品页截图裁剪(内联假证据)' : '来源:尚无可信图片' ;
$ ( '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: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:35:31 +08:00
$ ( 'reset' ). addEventListener ( 'click' , () => { $ ( 'scenario' ). value = 'idle' ; $ ( '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 )));
$ ( 'record-dialog-close' ). addEventListener ( 'click' , () => $ ( 'record-dialog' ). close ());
$ ( 'record-dialog-done' ). addEventListener ( 'click' , () => $ ( 'record-dialog' ). close ());
$ ( 'record-dialog' ). addEventListener ( 'close' , () => { if ( recordFocusReturn ? . isConnected ) recordFocusReturn . focus (); });
document . addEventListener ( 'pointerdown' , ( event ) => { if ( ! $ ( 'record-menu' ). hidden && ! $ ( 'record-menu' ). contains ( event . target )) hideRecordMenu (); });
document . addEventListener ( 'keydown' , ( event ) => { if ( event . key === 'Escape' && ! $ ( 'record-menu' ). hidden ) hideRecordMenu (); });
window . addEventListener ( 'resize' , hideRecordMenu );
2026-08-03 12:36:16 +08:00
$ ( 'export-log' ). addEventListener ( 'click' , exportDemo );
$ ( 'close-demo' ). addEventListener ( 'click' , () => {
const unsafe = [ 'trial' , 'dry_run' , 'order_prefence' , 'fencing' , 'fenced' ]. includes ( $ ( 'scenario' ). value );
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 >