@@ -4,113 +4,179 @@
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
< link rel = "icon" href = "data:," >
< title > 任务工作台 · cmbuyer 原型< / title >
< title > 采购 任务 · cmbuyer 原型< / title >
< style >
: root {
--bg : #f4f7fb ; --surface : #fff ; --surface-subtle : #f8fafc ; --text : #172033 ; --muted : #526079 ;
--border : #cfd8e6 ; --primary : #155eef ; --primary-hover : #0b4ed1 ; --primary-soft : #eaf1ff ;
--danger : #b42318 ; --danger-soft : #fef3f2 ; --success : #067647 ; --success-soft : #ecfdf3 ;
--warning : #8a4b0f ; --warning-soft : #fff7e8 ; --focus : #ffbf47 ; --shadow : 0 5 px 16 px rgba ( 23 , 32 , 51 , .07 ) ;
font-family : "Segoe UI" , "Microsoft YaHei UI" , system-ui , sans-serif ;
--bg : #f4f7fb ; --surface : #fff ; --subtle : #f8fafc ; --text : #172033 ; --muted : #526079 ;
--border : #cfd8e6 ; --border-strong : #91a0b4 ; --primary : #155eef ; --primary-hover : #0b4ed1 ;
--primary-soft : #eaf1ff ; --danger : #b42318 ; --danger-soft : #fef3f2 ; --success : #067647 ;
--success-soft : #ecfdf3 ; --warning : #8a4b0f ; --warning-soft : #fff7e8 ; --focus : #ffbf47 ;
--shadow : 0 7 px 24 px rgba ( 23 , 32 , 51 , .08 ) ;
font-family : "Segoe UI" , "Microsoft YaHei UI" , system-ui , sans-serif ;
}
* { box-sizing : border-box ; }
html { min-width : 320 px ; background : var ( - - bg ) ; }
body { margin : 0 ; min-height : 100 dvh ; color : var ( - - text ) ; background : var ( - - bg ) ; font-size : 16 px ; line-height : 1.5 ; }
a { color : var ( - - primary ) ; text-underline-offset : 3 px ; }
button , input , select { font : inherit ; }
button , input , select , . button { min-height : 44 px ; }
button { cursor : pointer ; }
button : disabled { cursor : not-allowed ; opacity : .6 ; }
: focus-visible { outline : 3 px solid var ( - - focus ) ; outline-offset : 3 px ; }
. skip-link { position : fixed ; z-index : 1000 ; top : 8 px ; left : 8 px ; padding : 10 px 14 px ; color : #fff ; background : var ( - - text ) ; transform : translateY ( -160 % ) ; }
. skip-link : focus { transform : translateY ( 0 ) ; }
. prototype-banner { position : sticky ; z-index : 50 ; top : 0 ; padding : 7 px 16 px ; color : #fff ; background : #29364d ; text-align : center ; font-size : .78 rem ; font-weight : 700 ; letter-spacing : .04 em ; }
. app-header { position : sticky ; z-index : 40 ; top : 33 px ; min-height : 64 px ; display : flex ; align-items : center ; gap : 24 px ; padding : 0 max ( 16 px , calc ( ( 100 vw - 1400 px ) / 2 ) ) ; border-bottom : 1 px solid var ( - - border ) ; background : rgba ( 255 , 255 , 255 , .98 ) ; }
. brand { display : flex ; align-items : center ; gap : 10 px ; color : var ( - - text ) ; text-decoration : none ; font-weight : 750 ; white-space : nowrap ; }
. brand-mark { display : grid ; place-items : center ; width : 32 px ; height : 32 px ; border-radius : 8 px ; color : #fff ; background : var ( - - primary ) ; font-size : .8 rem ; }
nav . primary { display : flex ; align-items : stretch ; align-self : stretch ; gap : 4 px ; }
nav . primary a { display : flex ; align-items : center ; padding : 0 12 px ; color : #3d4b63 ; text-decoration : none ; font-weight : 650 ; border-bottom : 3 px solid transparent ; }
nav . primary a [ aria-current = "page" ] { color : var ( - - primary ) ; border-bottom-color : var ( - - primary ) ; }
. header-account { margin-left : auto ; color : var ( - - muted ) ; font-size : .88 rem ; white-space : nowrap ; }
main { width : min ( 100 % - 32 px , 1400 px ) ; margin : 0 auto ; padding : 26 px 0 64 px ; }
. page-head { display : flex ; justify-content : space-between ; align-items : end ; gap : 20 px ; margin-bottom : 18 px ; }
h1 { margin : 0 ; font-size : clamp ( 1.6 rem , 3 vw , 2 rem ) ; }
. subtitle { margin : 5 px 0 0 ; color : var ( - - muted ) ; }
. button { display : inline-flex ; align-items : center ; justify-content : center ; gap : 8 px ; padding : 9 px 16 px ; border : 1 px solid transparent ; border-radius : 8 px ; text-decoration : none ; font-weight : 700 ; }
. button-primary { color : #ff f ; background : var ( - - primary ) ; }
. button-primary : hover { background : var ( - - primary - hover ) ; }
. button-secondary { color : var ( - - text ) ; border-color : var ( - - border ) ; background : var ( - - surface ) ; }
. button-secondary : hover { background : var ( - - surface - subtle ) ; }
. query-panel { display : grid ; grid-template-columns : minmax ( 220 px , 1 fr ) 190 px 190 px auto ; gap : 12 px ; align-items : end ; padding : 16 px ; border : 1 px solid var ( - - border ) ; border-radius : 12 px ; background : var ( - - surface ) ; box-shadow : var ( - - shadow ) ; }
label { display : block ; margin-bottom : 5 px ; color : #34425b ; font-size : .84 rem ; font-weight : 700 ; }
input , select { width : 100 % ; padding : 9 px 11 px ; border : 1 px solid #91a0b4 ; border-radius : 8 px ; color : var ( - - text ) ; background : #fff ; }
. query-hint { margin : 8 px 0 0 ; color : var ( - - muted ) ; font-size : .8 rem ; }
. summary-line { display : flex ; flex-wrap : wrap ; justify-content : space-between ; gap : 10 px ; margin : 16 px 2 px 10 px ; color : var ( - - muted ) ; font-size : .86 rem ; }
. demo-controls { display : flex ; flex-wrap : wrap ; align-items : center ; gap : 7 px ; }
. demo-controls strong { margin-right : 2 px ; }
. demo-controls button { min-height : 34 px ; padding : 5 px 9 px ; border : 1 px solid var ( - - border ) ; border-radius : 7 px ; color : #33415b ; background : var ( - - surface - subtle ) ; font-size : .78 rem ; }
. lanes { display : grid ; grid-template-columns : repeat ( 3 , minmax ( 0 , 1 fr ) ) ; gap : 14 px ; align-items : start ; }
. lane { min-width : 0 ; border : 1 px solid var ( - - border ) ; border-radius : 12 px ; background : #ed f2f8 ; overflow : clip ; }
. lane [ hidden ] { display : none ; }
. lane-head { display : flex ; align-items : center ; justify-content : space-between ; gap : 8 px ; padding : 14 px 14 px 12 px ; border-bottom : 1 px solid var ( - - border ) ; background : var ( - - surface - subtle ) ; }
. lane-title { display : flex ; align-items : center ; gap : 8 px ; min-width : 0 ; }
. lane-title h2 { margin : 0 ; font-size : 1 rem ; }
. lane-mark { width : 10 px ; height : 10 px ; flex : 0 0 auto ; border-radius : 50 % ; background : var ( - - primary ) ; }
. lane [ data-lane = "machine" ] . lane-mark { background : #526079 ; }
. lane [ data-lane = "don e" ] . lane-mark { background : var ( - - success ) ; }
. count { display : inline - grid ; min-width : 28 px ; height : 24 px ; place-items : center ; padding : 0 7 px ; border-radius : 999 px ; color : #34425b ; background : #e2e8f0 ; font-size : .78 rem ; font-weight : 750 ; font-variant-numeric : tabular-nums ; }
. lane-description { margin : 0 ; padding : 0 14 px 10 px ; color : var ( - - muted ) ; background : var ( - - surface - subtle ) ; font-size : .79 rem ; }
. task-list { display : grid ; gap : 10 px ; padding : 10 px ; }
. task-card { min-width : 0 ; border : 1 px solid #c6d0de ; border-radius : 9 px ; background : var ( - - surface ) ; box-shadow : 0 2 px 5 px rgba ( 23 , 32 , 51 , .04 ) ; overflow : hidden ; }
. task-card [ hidden ] { display : none ; }
. card-top { display : flex ; align-items : start ; justify-content : space-between ; gap : 8 px ; padding : 12 px ; }
. task-id { color : var ( - - muted ) ; font-size : .76 rem ; font-variant-numeric : tabular-nums ; }
. task-card h3 { margin : 3 px 0 0 ; font-size : .96 rem ; line-height : 1.35 ; overflow-wrap : anywhere ; }
. badge { flex : 0 0 auto ; padding : 4 px 7 px ; border : 1 px solid #b9cffc ; border-radius : 999 px ; color : #163c8c ; background : var ( - - primary - soft ) ; font-size : .72 rem ; font-weight : 700 ; }
. badge . warning { color : var ( - - warning ) ; border-color : #f0cd86 ; background : var ( - - warning - soft ) ; }
. badge . success { color : var ( - - success ) ; border-color : #a6e3c4 ; background : var ( - - success - soft ) ; }
. badge . danger { color : var ( - - danger ) ; border-color : #f3b7b2 ; background : var ( - - danger - soft ) ; }
. task-meta { display : grid ; grid-template-columns : 1 fr 1 fr ; gap : 6 px 10 px ; margin : 0 ; padding : 0 12 px 12 px ; }
. task-meta div { min-width : 0 ; }
. task-meta dt { color : var ( - - muted ) ; font-size : .72 rem ; }
. task-meta dd { margin : 1 px 0 0 ; font-size : .84 rem ; overflow-wrap : anywhere ; font-variant-numeric : tabular-nums ; }
details { border-top : 1 px solid var ( - - border ) ; }
summary { min-height : 44 px ; padding : 11 px 12 px ; cursor : pointer ; color : var ( - - primary ) ; font-size : .84 rem ; font-weight : 700 ; }
. decision-preview { padding : 0 12 px 12 px ; }
. decision-preview p { margin : 0 0 8 px ; color : #34425b ; font-size : .84 rem ; }
. decision-preview . button { width : 100 % ; min-height : 40 px ; padding : 7 px 10 px ; font-size : .84 rem ; }
. card-footer { display : flex ; justify-content : space-between ; gap : 8 px ; align-items : center ; padding : 9 px 12 px ; border-top : 1 px solid #e5eaf1 ; color : var ( - - muted ) ; font-size : .75 rem ; }
. card-footer a { font-weight : 700 ; }
. panel-state { display : none ; min-height : 320 px ; place-items : center ; padding : 36 px 20 px ; border : 1 px solid var ( - - border ) ; border-radius : 12 px ; background : var ( - - surface ) ; text-align : center ; }
. panel-state . show { display : grid ; }
. panel-state h2 { margin : 0 0 6 px ; font-size : 1.1 rem ; }
. panel-state p { max-width : 520 px ; margin : 0 0 14 px ; color : var ( - - muted ) ; }
. panel-state . error { border-color : #f3b7b2 ; background : var ( - - danger - soft ) ; }
. skeletons { width : min ( 100 % , 700 px ) ; display : grid ; gap : 12 px ; }
. skeleton { height : 86 px ; border-radius : 10 px ; background : linear-gradient ( 90 deg , #e9edf3 25 % , #f5f7fa 37 % , #e9edf3 63 % ) ; background-size : 400 % 100 % ; animation : shimmer 1.4 s ease infinite ; }
@ keyframes shimmer { to { background-position : -100 % 0 ; } }
@ media ( max-width : 1100px ) { . lanes { grid-template-columns : 1 fr ; } . lane { max-width : none ; } }
@ media ( max-width : 900px ) { . query-panel { grid-template-columns : 1 fr 1 fr ; } . query-panel . keyword { grid-column : 1 / -1 ; } }
@ media ( max-width : 767px ) {
. app-header { top : 33 px ; padding-inline : 16 px ; gap : 10 px ; }
. header-account { display : none ; }
nav . primary a { padding-inline : 8 px ; }
main { width : min ( 100 % - 24 px , 1400 px ) ; padding-top : 20 px ; }
. page-head { align-items : start ; flex-direction : column ; }
. page-head . button { width : 100 % ; }
. query-panel { grid-template-columns : 1 fr ; }
. query-panel . keyword { grid-column : auto ; }
. query-panel . button { width : 100 % ; }
. summary-line { align-items : start ; flex-direction : column ; }
. task-meta { grid-template-columns : 1 fr ; }
* { box-sizing : border-box ; }
html { min-width : 320 px ; background : var ( - - bg ) ; }
body { margin : 0 ; min-height : 100 dvh ; color : var ( - - text ) ; background : var ( - - bg ) ; font-size : 16 px ; line-height : 1.5 ; }
body : has ( dialog [ open ] ) { overflow : hidden ; }
a { color : var ( - - primary ) ; text-underline-offset : 3 px ; }
button , input , select { font : inherit ; }
button , . button , input , select { min-height : 44 px ; }
button { cursor : pointer ; }
button : disabled { cursor : not-allowed ; opacity : .56 ; }
: focus-visible { outline : 3 px solid var ( - - focus ) ; outline-offset : 2 px ; }
. skip-link { position : fixed ; z-index : 1000 ; top : 8 px ; left : 8 px ; padding : 10 px 14 px ; color : #fff ; background : var ( - - text ) ; transform : translateY ( -160 % ) ; }
. skip-link : focus { transform : translateY ( 0 ) ; }
. prototype-banner { position : sticky ; z-index : 50 ; top : 0 ; padding : 7 px 16 px ; color : #fff ; background : #29364d ; text-align : center ; font-size : .78 rem ; font-weight : 700 ; letter-spacing : .04 em ; }
. app-header { position : sticky ; z-index : 40 ; top : 33 px ; min-height : 64 px ; display : flex ; align-items : center ; gap : 24 px ; padding : 0 max ( 16 px , calc ( ( 100 vw - 1480 px ) / 2 ) ) ; border-bottom : 1 px solid var ( - - border ) ; background : rgba ( 255 , 255 , 255 , .98 ) ; }
. brand { display : flex ; align-items : center ; gap : 10 px ; color : var ( - - text ) ; text-decoration : none ; font-weight : 750 ; white-space : nowrap ; }
. brand-mark { display : grid ; place-items : center ; width : 32 px ; height : 32 px ; border-radius : 8 px ; color : #fff ; background : var ( - - primary ) ; font-size : .8 rem ; }
nav . primary { display : flex ; align-items : stretch ; align-self : stretch ; gap : 4 px ; }
nav . primary a { display : flex ; align-items : center ; padding : 0 12 px ; color : #3d4b63 ; text-decoration : none ; font-weight : 650 ; border-bottom : 3 px solid transparent ; }
nav . primary a [ aria-current = "page" ] { color : var ( - - primary ) ; border-bottom-color : var ( - - primary ) ; }
. header-account { margin-left : auto ; color : var ( - - muted ) ; font-size : .88 rem ; white-space : nowrap ; }
main { width : min ( 100 % - 32 px , 1480 px ) ; margin : 0 auto ; padding : 24 px 0 64 px ; }
. page-head { display : flex ; justify-content : space-between ; align-items : end ; gap : 20 px ; margin-bottom : 17 px ; }
h1 { margin : 0 ; font-size : clamp ( 1.55 rem , 3 vw , 2 rem ) ; }
. subtitle { margin : 5 px 0 0 ; color : var ( - - muted ) ; }
. safety-note { max-width : 560 px ; margin : 0 ; color : #29466 f ; font-size : .84 rem ; text-align : right ; }
. safety-note strong { color : #163c8c ; }
. workbench { border : 1 px solid var ( - - border ) ; border-radius : 12 px ; background : var ( - - surface ) ; box-shadow : var ( - - shadow ) ; overflow : hidden ; }
. toolbar { border-bottom : 1 px solid var ( - - border ) ; background : var ( - - surface ) ; }
. toolbar-row { min-height : 68 px ; display : flex ; align-items : center ; justify-content : space-between ; gap : 12 px ; padding : 11 px 14 px ; }
. toolbar-row + . toolbar-row { border-top : 1 px solid #e6ebf2 ; background : var ( - - subtle ) ; }
. toolbar-group { display : flex ; flex-wrap : wrap ; align-items : center ; gap : 9 px ; }
. toolbar-hint { margin : 0 ; color : var ( - - muted ) ; font-size : .8 rem ; }
. button { display : inline-flex ; align-items : center ; justify-content : center ; gap : 7 px ; padding : 8 px 14 px ; border : 1 px solid transparent ; border-radius : 8 px ; text-decoration : none ; font-weight : 700 ; white-space : nowrap ; }
. button svg { width : 17 px ; height : 17 px ; fill : none ; stroke : currentColor ; stroke-width : 2 ; stroke-linecap : round ; stroke-linejoin : round ; }
. button-primary { color : #fff ; background : var ( - - primary ) ; }
. button-primary : hover : not ( : disabled ) { background : var ( - - primary - hover ) ; }
. button-secondary { color : var ( - - text ) ; border-color : var ( - - border ) ; background : var ( - - surface ) ; }
. button-secondary : hover : not ( : disabled ) { background : #ee f2f7 ; }
. button-quiet { color : #34425b ; border-color : transparent ; background : transparent ; }
. button-quiet : hover : not ( : disabled ) { background : #e9eef5 ; }
. filter-form { width : 100 % ; display : flex ; align-items : end ; justify-content : space-between ; gap : 12 px ; }
. filter-field { width : min ( 100 % , 520 px ) ; }
. filter-field label , . field label { display : block ; margin-bottom : 5 px ; color : #34425b ; font-size : .82 rem ; font-weight : 700 ; }
input , select { width : 100 % ; padding : 8 px 11 px ; border : 1 px solid var ( - - border - strong ) ; border-radius : 8 px ; color : var ( - - text ) ; background : #fff ; }
input [ aria-invalid = "tru e" ] { border-color : var ( - - danger ) ; box-shadow : 0 0 0 1 px var ( - - danger ) ; }
. filter-actions { display : flex ; align-items : end ; gap : 9 px ; }
. advanced-filters { position : relative ; }
. advanced-filters > summary { min-height : 44 px ; display : flex ; align-items : center ; padding : 8 px 12 px ; border : 1 px solid var ( - - border ) ; border-radius : 8 px ; color : #34425b ; background : #fff ; cursor : pointer ; font-weight : 700 ; white-space : nowrap ; }
. advanced-filters [ open ] > summary { color : #163c8c ; border-color : #8aacf2 ; background : var ( - - primary - soft ) ; }
. filter-popover { position : absolute ; z-index : 12 ; top : 51 px ; right : 0 ; width : 310 px ; display : grid ; gap : 11 px ; padding : 13 px ; border : 1 px solid var ( - - border ) ; border-radius : 9 px ; background : #fff ; box-shadow : var ( - - shadow ) ; }
. filter-popover label { display : block ; margin-bottom : 4 px ; color : #34425b ; font-size : .78 rem ; font-weight : 700 ; }
. selection-mode { width : 100 % ; display : flex ; align-items : center ; justify-content : space-between ; gap : 12 px ; }
. selection-mode [ hidden ] , . filter-form [ hidden ] { display : none ; }
. selection-copy { min-width : 0 ; }
. selection-copy strong { display : block ; color : #163c8c ; }
. selection-copy span { color : var ( - - muted ) ; font-size : .8 rem ; }
. table-summary { min-height : 45 px ; display : flex ; flex-wrap : wrap ; align-items : center ; justify-content : space-between ; gap : 8 px ; padding : 8 px 14 px ; color : var ( - - muted ) ; font-size : .82 rem ; border-bottom : 1 px solid #e5eaf1 ; }
. prototype-controls { position : relative ; }
. prototype-controls summary { cursor : pointer ; color : #34425b ; font-weight : 700 ; }
. demo-menu { position : absolute ; z-index : 10 ; right : 0 ; top : 30 px ; width : 220 px ; display : grid ; gap : 6 px ; padding : 9 px ; border : 1 px solid var ( - - border ) ; border-radius : 9 px ; background : #fff ; box-shadow : var ( - - shadow ) ; }
. demo-menu button { min-height : 38 px ; padding : 6 px 9 px ; border : 1 px solid var ( - - border ) ; border-radius : 7 px ; color : #34425b ; background : var ( - - subtle ) ; text-align : left ; }
. table-scroll { width : 100 % ; overflow : auto ; scrollbar-gutter : stable ; }
table { width : 100 % ; min-width : 1180 px ; border-collapse : separate ; border-spacing : 0 ; table-layout : fixed ; }
th , td { padding : 11 px 10 px ; border-bottom : 1 px solid #e3e8ef ; text-align : left ; vertical-align : middle ; overflow-wrap : anywhere ; }
th { position : sticky ; top : 0 ; z-index : 4 ; color : #3b4960 ; background : #f1f5f9 ; font-size : .78 rem ; font-weight : 750 ; white-space : nowrap ; }
th : first-child , td : first-child { position : sticky ; left : 0 ; z-index : 3 ; width : 52 px ; text-align : center ; background : inherit ; }
th : nth-child ( 2 ) , td : nth-child ( 2 ) { position : sticky ; left : 52 px ; z-index : 3 ; width : 270 px ; background : inherit ; box-shadow : 1 px 0 0 #dce3ec ; }
th : first-child , th : nth-child ( 2 ) { z-index : 6 ; background : #f1f5f9 ; }
th : nth-child ( 3 ) { width : 105 px ; } th : nth-child ( 4 ) { width : 90 px ; } th : nth-child ( 5 ) { width : 108 px ; }
th : nth-child ( 6 ) { width : 72 px ; } th : nth-child ( 7 ) { width : 260 px ; } th : nth-child ( 8 ) { width : 130 px ; } th : nth-child ( 9 ) { width : 166 px ; }
tbody tr { background : #fff ; }
tbody tr : nth-child ( even ) { background : #fbfcfe ; }
tbody tr : hover { background : #edf4ff ; }
tbody tr : focus { position : relative ; z-index : 2 ; background : #eaf1ff ; }
tbody tr [ data-selected = "true" ] { background : #eaf1ff ; }
tbody tr [ data-new = "true" ] { animation : new-row 1.1 s ease-out ; }
td { font-size : .87 rem ; }
. row-check , . head-check { width : 18 px ; height : 18 px ; margin : 0 ; accent-color : var ( - - primary ) ; }
. product-link { display : inline-flex ; align-items : start ; gap : 5 px ; color : #173e91 ; font-weight : 700 ; line-height : 1.35 ; }
. product-link svg { width : 14 px ; height : 14 px ; flex : 0 0 auto ; margin-top : 2 px ; fill : none ; stroke : currentColor ; stroke-width : 2 ; }
. task-link { display : block ; width : max-content ; max-width : 100 % ; margin-top : 4 px ; color : var ( - - muted ) ; font-size : .75 rem ; font-variant-numeric : tabular-nums ; }
. money , . quantity , . created { font-variant-numeric : tabular-nums ; }
. money , . quantity { text-align : right ; }
. result { color : #34425b ; font-variant-numeric : tabular-nums ; }
. result . empty { color : #7b8799 ; }
. status { display : inline-flex ; align-items : center ; gap : 6 px ; padding : 4 px 8 px ; border : 1 px solid #b9cffc ; border-radius : 999 px ; color : #163c8c ; background : var ( - - primary - soft ) ; font-size : .74 rem ; font-weight : 750 ; white-space : nowrap ; }
. status :: before { content : "" ; width : 7 px ; height : 7 px ; border-radius : 50 % ; background : currentColor ; }
. status . warning { color : var ( - - warning ) ; border-color : #e8c983 ; background : var ( - - warning - soft ) ; }
. status . success { color : var ( - - success ) ; border-color : #a6e3c4 ; background : var ( - - success - soft ) ; }
. status . danger { color : var ( - - danger ) ; border-color : #f3b7b2 ; background : var ( - - danger - soft ) ; }
. status . neutral { color : #526079 ; border-color : #cfd8e6 ; background : #f1f5f9 ; }
. table-state { min-height : 320 px ; display : none ; place-items : center ; padding : 36 px 20 px ; text-align : center ; background : #fff ; }
. table-state . show { display : grid ; }
. table-state h2 { margin : 0 0 6 px ; font-size : 1.08 rem ; }
. table-state p { max-width : 520 px ; margin : 0 0 14 px ; color : var ( - - muted ) ; }
. table-state . error { color : var ( - - danger ) ; background : var ( - - danger - soft ) ; }
. skeletons { width : min ( 100 % , 850 px ) ; display : grid ; gap : 10 px ; }
. skeleton { height : 45 px ; border-radius : 7 px ; background : linear-gradient ( 90 deg , #e9edf3 25 % , #f7f9fb 37 % , #e9edf3 63 % ) ; background-size : 400 % 100 % ; animation : shimmer 1.4 s ease infinite ; }
. toast { position : fixed ; z-index : 120 ; right : 18 px ; bottom : 18 px ; width : min ( 420 px , calc ( 100 % - 36 px ) ) ; padding : 12 px 14 px ; border : 1 px solid #a6e3c4 ; border-radius : 9 px ; color : var ( - - success ) ; background : var ( - - success - soft ) ; box-shadow : var ( - - shadow ) ; }
. toast [ hidden ] { display : none ; }
dialog { color : var ( - - text ) ; }
dialog :: backdrop { background : rgba ( 15 , 23 , 42 , .58 ) ; }
. create-dialog { width : min ( 720 px , calc ( 100 % - 28 px ) ) ; max-height : calc ( 100 dvh - 28 px ) ; padding : 0 ; border : 0 ; border-radius : 13 px ; box-shadow : 0 24 px 80 px rgba ( 0 , 0 , 0 , .3 ) ; }
. dialog-head { display : flex ; align-items : start ; justify-content : space-between ; gap : 14 px ; padding : 17 px 20 px 13 px ; border-bottom : 1 px solid var ( - - border ) ; }
. dialog-head h2 { margin : 0 ; font-size : 1.15 rem ; }
. dialog-head p { margin : 4 px 0 0 ; color : var ( - - muted ) ; font-size : .84 rem ; }
. icon-button { width : 44 px ; height : 44 px ; display : grid ; flex : 0 0 auto ; place-items : center ; padding : 0 ; border : 1 px solid var ( - - border ) ; border-radius : 8 px ; color : #34425b ; background : #fff ; }
. icon-button svg { width : 19 px ; height : 19 px ; fill : none ; stroke : currentColor ; stroke-width : 2 ; }
. create-form { padding : 18 px 20 px 20 px ; }
. form-grid { display : grid ; grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 13 px 15 px ; }
. field . full { grid-column : 1 /- 1 ; }
. field-hint , . field-error { min-height : 1.2 em ; margin : 4 px 0 0 ; font-size : .76 rem ; }
. field-hint { color : var ( - - muted ) ; }
. field-error { color : var ( - - danger ) ; font-weight : 650 ; }
. error-summary { display : none ; margin : 0 0 14 px ; padding : 10 px 12 px ; border : 1 px solid #f3b7b2 ; border-radius : 8 px ; color : var ( - - danger ) ; background : var ( - - danger - soft ) ; }
. error-summary . show { display : block ; }
. dialog-actions { display : flex ; flex-wrap : wrap ; justify-content : flex-end ; gap : 9 px ; margin-top : 18 px ; padding-top : 15 px ; border-top : 1 px solid var ( - - border ) ; }
. detail-drawer { width : min ( 860 px , 94 vw ) ; height : 100 dvh ; max-width : none ; max-height : none ; margin : 0 0 0 auto ; padding : 0 ; border : 0 ; border-radius : 0 ; box-shadow : -18 px 0 55 px rgba ( 0 , 0 , 0 , .22 ) ; overflow : hidden ; }
. detail-drawer [ open ] { display : grid ; grid-template-rows : auto minmax ( 0 , 1 fr ) auto ; }
. drawer-head { display : flex ; align-items : start ; justify-content : space-between ; gap : 16 px ; padding : 18 px 20 px 14 px ; border-bottom : 1 px solid var ( - - border ) ; background : #fff ; }
. drawer-head h2 { margin : 3 px 0 0 ; font-size : 1.28 rem ; line-height : 1.3 ; }
. drawer-eyebrow { margin : 0 ; color : var ( - - muted ) ; font-size : .76 rem ; font-variant-numeric : tabular-nums ; }
. drawer-body { min-height : 0 ; overflow-y : auto ; padding : 18 px 20 px 28 px ; background : var ( - - bg ) ; }
. drawer-safety { margin : 0 0 14 px ; padding : 11 px 13 px ; border : 1 px solid #b9cffc ; border-radius : 9 px ; color : #29466f ; background : var ( - - primary - soft ) ; }
. drawer-panel { margin-bottom : 13 px ; padding : 15 px ; border : 1 px solid var ( - - border ) ; border-radius : 10 px ; background : #fff ; }
. drawer-panel h3 { margin : 0 0 10 px ; font-size : 1 rem ; }
. detail-grid { display : grid ; grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 9 px ; margin : 0 ; }
. detail-grid div { min-width : 0 ; padding : 10 px 11 px ; border : 1 px solid #e1e7ef ; border-radius : 8 px ; background : var ( - - subtle ) ; }
. detail-grid dt { color : var ( - - muted ) ; font-size : .75 rem ; }
. detail-grid dd { margin : 2 px 0 0 ; font-weight : 650 ; overflow-wrap : anywhere ; }
. evidence-placeholder { min-height : 160 px ; display : grid ; place-items : center ; padding : 20 px ; border : 1 px dashed #aebacc ; border-radius : 9 px ; color : var ( - - muted ) ; background : #f1f5f9 ; text-align : center ; }
. next-step { padding : 11 px 13 px ; border-left : 4 px solid var ( - - primary ) ; border-radius : 6 px ; color : #29466f ; background : var ( - - primary - soft ) ; }
. drawer-actions { display : flex ; justify-content : flex-end ; gap : 9 px ; padding : 13 px 20 px ; border-top : 1 px solid var ( - - border ) ; background : #fff ; }
@ keyframes shimmer { to { background-position : -100 % 0 ; } }
@ keyframes new-row { from { background : #fff2bf ; } to { background : #fff ; } }
@ media ( max-width : 767px ) {
. app-header { top : 33 px ; padding-inline : 16 px ; gap : 10 px ; }
. header-account { display : none ; }
nav . primary a { padding-inline : 8 px ; }
main { width : min ( 100 % - 20 px , 1480 px ) ; padding-top : 18 px ; }
. page-head { align-items : start ; flex-direction : column ; }
. safety-note { text-align : left ; }
. toolbar-row , . filter-form , . selection-mode { align-items : stretch ; flex-direction : column ; }
. toolbar-group , . filter-field { width : 100 % ; }
. toolbar-group . button , . filter-form . button , . selection-mode . button { flex : 1 1 auto ; }
. filter-actions { width : 100 % ; align-items : stretch ; flex-direction : column ; }
. advanced-filters , . advanced-filters > summary { width : 100 % ; }
. filter-popover { position : static ; width : 100 % ; margin-top : 7 px ; box-shadow : none ; }
. form-grid , . detail-grid { grid-template-columns : 1 fr ; }
. field . full { grid-column : auto ; }
. create-dialog { width : 100 % ; max-height : 100 dvh ; height : 100 dvh ; margin : 0 ; border-radius : 0 ; }
. detail-drawer { width : 100 vw ; }
. drawer-actions { flex-direction : column-reverse ; }
. drawer-actions . button { width : 100 % ; }
}
@ media ( max-width : 430px ) {
. prototype-banner { position : static ; padding-inline : 8 px ; }
. app-header { top : 0 ; }
. brand span : last-child , nav . primary a : last-child { display : none ; }
@ media ( max-width : 430px ) {
. prototype-banner { position : static ; padding-inline : 8 px ; }
. app-header { top : 0 ; }
. brand span : last-child , nav . primary a : last-child { display : none ; }
. toolbar-group { display : grid ; grid-template-columns : 1 fr 1 fr ; }
}
@ media ( prefers-reduced-motion : reduce ) {
* , * :: before , * :: after { scroll-behavior : auto !important ; animation-duration : .01 ms !important ; animation-iteration-count : 1 !important ; transition-duration : .01 ms !important ; }
@ media ( prefers-reduced-motion : reduce ) {
* , * :: before , * :: after { scroll-behavior : auto !important ; animation-duration : .01 ms !important ; animation-iteration-count : 1 !important ; transition-duration : .01 ms !important ; }
}
< / style >
< / head >
@@ -119,156 +185,413 @@
< div class = "prototype-banner" > PROTOTYPE - 仅供枚举交互,非实现依据< / div >
< header class = "app-header" >
< a class = "brand" href = "web-task-workbench.html" > < span class = "brand-mark" aria-hidden = "true" > CM< / span > < span > cmbuyer< / span > < / a >
< nav class = "primary" aria-label = "主导航" > < a href = "web-task-workbench.html" aria-current = "page" > 任务工作台 < / a > < a href = "web-task-create.html" > 新建任务 < / a > < / nav >
< nav class = "primary" aria-label = "主导航" > < a href = "web-task-workbench.html" aria-current = "page" > 采购 任务< / a > < a href = "web-task-create.html" > 建单直达页 < / a > < / nav >
< span class = "header-account" > 采购管理员 · caigou_admin< / span >
< / header >
< main id = "main" >
< header class = "page-head" >
< div > < h1 > 任务工作台 < / h1 > < p class = "subtitle" > 按“谁持球”处理任务;第一条待决策任务默认展开 。< / p > < / div >
< a class = "button button-primary" href = "web-task-create.html" > 新建采购任务 < / a >
< div > < h1 > 采购 任务< / h1 > < p class = "subtitle" > 创建后先保存为待开始;勾选后统一进入第一趟试选队列 。< / p > < / div >
< p class = "safety-note" > < strong > 只开始试选,不会下单。 < / strong > 下单必须在试选完成并经人工确认后另行授权,付款始终由人完成。 < / p >
< / header >
< section class = "query-panel" aria-label = "任务查询 ">
< div class = "keyword" > < label for = "keyword" > 关键词 < / label > < input id = "keyword" type = "search" placeholder = "标题、规格或任务编号" autocomplete = "off" > < / div >
< div > < label for = "lane-filter" > 泳道 < / label > < select id = "lane-filter" > < option value = "all" > 全部泳道 < / option > < option value = "decision" > 等你决定 < / option > < option value = "machine" > 机器在跑 < / option > < option value = "done" > 已结束 < / option > < / select > < / div >
< div > < label for = "time-filter" > 时间范围 < / label > < select id = "time-filter" > < option value = "all" > 全部时间 < / option > < option value = "today" > 今天 < / option > < option value = "week" > 最近 7 天 < / option > < / select > < / div >
< button class = "button button-secondary" id = "clear-filters" type = "button" > 清除筛选 < / button >
< / section >
< p class = "query-hint" > 各泳道计数只随关键词与时间范围变化,不随泳道选择变化。 < / p >
< div class = "summary-line" >
< span id = "result-summary" aria-live = "polite" > 共 6 条假数据任务,其中 3 条等你决定。 < / span >
< div class = "demo-controls" aria-label = "原型状态控制" > < strong > 原型状态: < / strong > < button type = "button" data-demo = "default" > 默认 < / button > < button type = "button" data-demo = "loading" > 加载中 < / button > < button type = "button" data-demo = "empty" > 空结果 < / button > < button type = "button" data-demo = "error" > 加载失败 < / button > < / div >
< / div >
< section class = "panel-state" id = "loading-state" aria-live = "polite" >
< div class = "skeletons" aria-label = "正在加载任务" > < div class = "skeleton" > < / div > < div class = "skeleton" > < / div > < div class = "skeleton" > < / div > < p > 正在加载任务,请稍候…< / p > < / div >
< / section >
< section class = "panel-state" id = "empty-state" >
< div > < h2 > 没有匹配的任务< / h2 > < p > 调整关键词或时间范围,或者清除全部筛选条件。< / p > < button class = "button button-primary" type = "button" data-action = "clear" > 清除筛选< / button > < / div >
< / section >
< section class = "panel-state error" id = "error-state" role = "alert" >
< div > < h2 > 任务列表加载失败< / h2 > < p > 原型模拟服务暂时不可用。当前筛选已保留,可以安全重试。< / p > < button class = "button button-primary" type = "button" data-action = "retry" > 重试加载< / button > < / div >
< / section >
< section class = "lanes" id = "lanes" aria-label = "任务泳道" >
< section class = "lane" data-lane = "decision" aria-labelledby = "decision-title" >
< header class = "lane-head" > < div class = "lane-title" > < span class = "lane-mark" aria-hidden = "true" > < / span > < h2 id = "decision-title" > 等你决定< / h2 > < / div > < span class = "count" id = "count-decision" > 3< / span > < / header >
< p class = "lane-description" > 确认试选、核对待付款或处理结果不明。< / p >
< div class = "task-list" >
< article class = "task-card" data-task data-lane-name = "decision" data-date = "today" data-search = "T-20260803-018 工作服 白色 XL 等待确认" >
< div class = "card-top" > < div > < span class = "task-id" > T-20260803-018< / span > < h3 > 8 月仓库工作服补货< / h3 > < / div > < span class = "badge" > 等待确认< / span > < / div >
< dl class = "task-meta" > < div > < dt > 要求< / dt > < dd > 白色 · XL · 2 件< / dd > < / div > < div > < dt > 机器选到< / dt > < dd > 白色 · XL< / dd > < / div > < div > < dt > 读到单价< / dt > < dd > ¥32.50< / dd > < / div > < div > < dt > 上限 / 合计< / dt > < dd > ¥80.00 / ¥65.00< / dd > < / div > < / dl >
< details open > < summary > 已展开:机器选对了吗?< / summary > < div class = "decision-preview" > < p > < strong > 规格一致、金额未超上限。< / strong > 确认前仍需查看规格面板截图。< / p > < a class = "button button-primary" href = "web-task-detail.html" > 查看证据并决定< / a > < / div > < / details >
< footer class = "card-footer" > < span > 更新于 10:42< / span > < a href = "web-task-detail.html" > 详情< / a > < / footer >
< / article >
< article class = "task-card" data-task data-lane-name = "decision" data-date = "today" data-search = "T-20260803-016 办公室收纳盒 透明 L 结果不明 调和" >
< div class = "card-top" > < div > < span class = "task-id" > T-20260803-016< / span > < h3 > 办公室收纳盒< / h3 > < / div > < span class = "badge warning" > 结果不明< / span > < / div >
< dl class = "task-meta" > < div > < dt > 状态< / dt > < dd > 可能已创建订单< / dd > < / div > < div > < dt > 提交记录< / dt > < dd > SUB-016-A< / dd > < / div > < / dl >
< footer class = "card-footer" > < span > 更新于 09:58< / span > < a href = "web-task-detail.html" > 人工核查< / a > < / footer >
< / article >
< article class = "task-card" data-task data-lane-name = "decision" data-date = "week" data-search = "T-20260801-009 标签打印纸 白色 50x30 待付款" >
< div class = "card-top" > < div > < span class = "task-id" > T-20260801-009< / span > < h3 > 标签打印纸补货< / h3 > < / div > < span class = "badge warning" > 待付款< / span > < / div >
< dl class = "task-meta" > < div > < dt > 规格< / dt > < dd > 白色 · 50× 30< / dd > < / div > < div > < dt > 授权金额< / dt > < dd > ¥46.00< / dd > < / div > < / dl >
< footer class = "card-footer" > < span > 更新于 8 月 1 日< / span > < a href = "web-task-detail.html" > 核对订单< / a > < / footer >
< / article >
< section class = "workbench" aria-label = "采购任务表格工作台 ">
< div class = "toolbar" >
< div class = "toolbar-row" >
< div class = "toolbar-group" >
< button class = "button button-secondary" type = "button" disabled aria-describedby = "import-hint" >
< svg viewBox = "0 0 24 24" aria-hidden = "true" > < path d = "M12 3v12m0 0 4-4m-4 4-4-4M5 19h14" / > < / svg > 导入
< / button >
< button class = "button button-primary" id = "open-create" type = "button" >
< svg viewBox = "0 0 24 24" aria-hidden = "true" > < path d = "M12 5v14M5 12h14" / > < / svg > 创建
< / button >
< / div >
< p class = "toolbar-hint" id = "import-hint" > 导入暂未开放;MVP 只支持手工创建。 < / p >
< / div >
< / section >
< section class = "lane" data-lane = "machine" aria-labelledby = "machine-title ">
< header class = "lane-head" > < div class = "lane-title" > < span class = "lane-mark" aria-hidden = "true" > < / span > < h2 id = "machine-title" > 机器在跑 < / h2 > < / div > < span class = "count " id = "count-machine" > 2 < / span > < / header >
< p class = "lane-description" > 待领取、试选、授权后第二趟与围栏前执行。 < / p >
< div class = "task-list " >
< article class = "task-card" data-task data-lane-name = "machine" data-date = "today" data-search = "T-20260803-019 工作服 深灰 L 待领取" >
< div class = "card-top" > < div > < span class = "task-id" > T-20260803-019 < / span > < h3 > 新建的工作服任务 < / h3 > < / div > < span class = "badge "> 待领取 < / span > < / div >
< dl class = "task-meta" > < div > < dt > 要求 < / dt > < dd > 深灰 · L · 1 件 < / dd > < / div > < div > < dt > 价格上限 < / dt > < dd > ¥45.00 < / dd > < / div > < / dl >
< footer class = "card-footer" > < span > 更新于 10:47 < / span > < a href = "web-task-detail.html" > 查看进度 < / a > < / footer >
< / article >
< article class = "task-card" data-task data-lane-name = "machine" data-date = "today" data-search = "T-20260803-017 防滑手套 黑色 均码 第二趟 围栏前" >
< div class = "card-top" > < div > < span class = "task-id" > T-20260803-017 < / span > < h3 > 仓库防滑手套 < / h3 > < / div > < span class = "badge" > 第二趟 · 围栏前 < / span > < / div >
< dl class = "task-meta" > < div > < dt > 锁定单价 < / dt > < dd > ¥12.80 < / dd > < / div > < div > < dt > 授权到期 < / dt > < dd > 11:25 < / dd > < / div > < / dl >
< footer class = "card-footer" > < span > 更新于 10:45< / span > < a href = "web-task-detail.html" > 查看授权< / a > < / footer >
< / article >
< div class = "toolbar-row " >
< form class = "filter-form" id = "filter-form " role = "search" >
< div class = "filter-field" > < label for = "keyword" > 标题关键词 < / label > < input id = "keyword" type = "search" placeholder = "输入采购任务标题" autocomplete = "off" > < / div >
< div class = "filter-actions " >
< details class = "advanced-filters" id = "advanced-filters" > < summary > 更多条件 < / summary > < div class = "filter-popover" > < div > < label for = "status-filter" > 状态 < / label > < select id = "status-filter" > < option value = "all" > 全部状态 < / option > < option value = "DRAFT" > 待开始 < / option > < option value = "PENDING" > 待领取 < / option > < option value = "WAITING_CONFIRMATION" > 等待确认 < / option > < option value = "WAITING_PAYMENT" > 待付款 < / option > < option value = "RECONCILIATION_REQUIRED" > 结果待核查 < / option > < option value = "SUCCEEDED" > 已完成 < / option > < / select > < / div > < div > < label for = "time-filter" > 创建时间 < / label > < select id = "time-filter" > < option value = "all" > 全部时间 < / option > < option value = "today" > 今天 < / option > < option value = "week" > 最近 7 天 < / option > < / select > < / div > < / div > < / details >
< div class = "toolbar-group" > < button class = "button button-primary" type = "submit" > 筛选 < / button > < button class = "button button-secondary" id = "clear-filter" type = "button "> 清除 < / button > < / div >
< / div >
< / form >
< div class = "selection-mode" id = "selection-mode" hidden >
< div class = "selection-copy" > < strong id = "selection-count" > 已选择 0 条 < / strong > < span > 只会加入第一趟试选队列,不签发授权、不创建订单、不付款。 < / span > < / div >
< div class = "toolbar-group" > < button class = "button button-primary" id = "start-trial" type = "button" > 开始试选 < / button > < button class = "button button-secondary" id = "clear-selection" type = "button" > 清除选择 < / button > < / div >
< / div >
< / div >
< / section >
< / div >
< section class = "lane" data-lane = "done" aria-labelledby = "done-title ">
< header class = "lane-head" > < div class = "lane-title" > < span class = "lane- mark " aria-hidden = "true" > < / span > < h2 id = "done-title" > 已结束 < / h2 > < / div > < span class = "count" id = "count-done" > 1 < / span > < / header >
< p class = "lane-description" > 已付款完成、失败或已取消的归档任务。 < / p >
< div class = "task-list" >
< article class = "task-card" data-task data-lane-name = "done" data-date = "week" data-search = "T-20260731-042 打包胶带 透明 45mm 已完成" >
< div class = "card-top" > < div > < span class = "task-id" > T-20260731-042 < / span > < h3 > 打包胶带补货 < / h3 > < / div > < span class = "badge success" > 已完成 < / span > < / div >
< dl class = "task-meta" > < div > < dt > 规格 < / dt > < dd > 透明 · 45mm < / dd > < / div > < div > < dt > 人工标记 < / dt > < dd > 采购管理员 < / dd > < / div > < / dl >
< footer class = "card-footer" > < span > 更新于 7 月 31 日 < / span > < a href = "web-task-detail.html" > 查看记录 < / a > < / footer >
< / article >
< / div >
< / section >
< div class = "table-summary " >
< span id = "result-sum mary " aria-live = "polite" > 正在准备假数据任务… < / span >
< details class = "prototype-controls" > < summary > 原型状态 < / summary > < div class = "demo-menu" > < button type = "button" data-demo = "default" > 默认表格 < / button > < button type = "button" data-demo = "loading" > 加载中 < / button > < button type = "button" data-demo = "empty" > 空结果 < / button > < button type = "button" data-demo = "error" > 加载失败 < / button > < / div > < / details >
< / div >
< div class = "table-scroll" id = "table-scroll" tabindex = "0" aria-label = "采购任务表格,可横向滚动" >
< table aria-describedby = "table-help" >
< caption id = "table-help" style = "position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0" > 双击非控件区域或聚焦行后按 Enter 查看详情;空格勾选待开始任务。 < / caption >
< thead > < tr > < th scope = "col" > < input class = "head-check" id = "select-all" type = "checkbox" aria-label = "选择当前筛选结果中全部待开始任务" > < / th > < th scope = "col" > 标题 < / th > < th scope = "col" > 颜色 < / th > < th scope = "col" > 尺码 < / th > < th scope = "col" class = "money" > 价格上限 < / th > < th scope = "col" class = "quantity" > 数量 < / th > < th scope = "col" > 采购结果 < span title = "固定顺序:颜色、尺码、单价、数量" > ⓘ < / span > < / th > < th scope = "col" > 状态 < / th > < th scope = "col" aria-sort = "descending" > 创建时间 ↓ < / th > < / tr > < / thead >
< tbody id = "task-rows" > < / tbody >
< / table >
< / div >
< section class = "table-state" id = "loading-state" aria-live = "polite" > < div class = "skeletons" > < div class = "skeleton" > < / div > < div class = "skeleton" > < / div > < div class = "skeleton" > < / div > < p > 正在加载采购任务,请稍候…< / p > < / div > < / section >
< section class = "table-state" id = "empty-state" > < div > < h2 > 没有匹配的采购任务< / h2 > < p > 更换标题关键词,或者清除当前筛选。< / p > < button class = "button button-primary" type = "button" data-action = "clear-filter" > 清除筛选< / button > < / div > < / section >
< section class = "table-state error" id = "error-state" role = "alert" > < div > < h2 > 任务列表加载失败< / h2 > < p > 当前筛选条件已保留。检查连接后可以安全重试。< / p > < button class = "button button-primary" type = "button" data-action = "retry" > 重试加载< / button > < / div > < / section >
< / section >
< / main >
< div class = "toast" id = "toast" role = "status" aria-live = "polite" hidden > < / div >
< dialog class = "create-dialog" id = "create-dialog" aria-labelledby = "create-title" >
< header class = "dialog-head" > < div > < h2 id = "create-title" > 创建采购任务< / h2 > < p > 保存后为待开始,不会立即驱动手机。< / p > < / div > < button class = "icon-button" type = "button" data-close-create aria-label = "关闭创建弹窗" > < svg viewBox = "0 0 24 24" aria-hidden = "true" > < path d = "m6 6 12 12M18 6 6 18" / > < / svg > < / button > < / header >
< form class = "create-form" id = "create-form" novalidate >
< div class = "error-summary" id = "create-error-summary" role = "alert" tabindex = "-1" > < / div >
< div class = "form-grid" >
< div class = "field full" > < label for = "create-name" > 任务标题 *< / label > < input id = "create-name" name = "title" value = "秋季仓库工作服补货" aria-describedby = "create-name-error" > < p class = "field-error" id = "create-name-error" > < / p > < / div >
< div class = "field full" > < label for = "create-url" > 拼多多商品链接 *< / label > < input id = "create-url" name = "product_url" type = "url" inputmode = "url" value = "https://mobile.yangkeduo.invalid/goods.html?goods_id=900000001" aria-describedby = "create-url-hint create-url-error" > < p class = "field-hint" id = "create-url-hint" > 原型使用 .invalid 假链接;生产环境需解析出 goods_id。< / p > < p class = "field-error" id = "create-url-error" > < / p > < / div >
< div class = "field" > < label for = "create-color" > 颜色 *< / label > < input id = "create-color" name = "color" value = "藏青色" aria-describedby = "create-color-error" > < p class = "field-error" id = "create-color-error" > < / p > < / div >
< div class = "field" > < label for = "create-size" > 尺码 *< / label > < input id = "create-size" name = "size" value = "XL" aria-describedby = "create-size-error" > < p class = "field-error" id = "create-size-error" > < / p > < / div >
< div class = "field" > < label for = "create-price" > 价格上限(总额) *< / label > < input id = "create-price" name = "max_price" type = "text" inputmode = "decimal" value = "80.00" aria-describedby = "create-price-hint create-price-error" > < p class = "field-hint" id = "create-price-hint" > 金额使用十进制字符串,最多两位小数。< / p > < p class = "field-error" id = "create-price-error" > < / p > < / div >
< div class = "field" > < label for = "create-quantity" > 数量 *< / label > < input id = "create-quantity" name = "quantity" type = "number" inputmode = "numeric" min = "1" step = "1" value = "2" aria-describedby = "create-quantity-error" > < p class = "field-error" id = "create-quantity-error" > < / p > < / div >
< / div >
< div class = "dialog-actions" > < button class = "button button-secondary" type = "button" data-close-create > 取消< / button > < button class = "button button-primary" type = "submit" > 保存任务< / button > < / div >
< / form >
< / dialog >
< dialog class = "detail-drawer" id = "detail-drawer" aria-labelledby = "drawer-title" >
< header class = "drawer-head" > < div > < p class = "drawer-eyebrow" id = "drawer-id" > 任务详情< / p > < h2 id = "drawer-title" tabindex = "-1" > 采购任务< / h2 > < / div > < button class = "icon-button" id = "close-drawer" type = "button" aria-label = "关闭任务详情" > < svg viewBox = "0 0 24 24" aria-hidden = "true" > < path d = "m6 6 12 12M18 6 6 18" / > < / svg > < / button > < / header >
< div class = "drawer-body" >
< p class = "drawer-safety" > < strong > 系统只创建待付款订单,绝不自动付款。< / strong > 当前详情使用假数据,不访问拼多多或真实接口。< / p >
< section class = "drawer-panel" > < h3 > 采购要求< / h3 > < dl class = "detail-grid" id = "drawer-requirements" > < / dl > < / section >
< section class = "drawer-panel" > < h3 > 采购结果< / h3 > < div id = "drawer-result" > < / div > < / section >
< section class = "drawer-panel" > < h3 > 当前状态与下一步< / h3 > < div class = "next-step" id = "drawer-next-step" > < / div > < / section >
< section class = "drawer-panel" > < h3 > 执行证据< / h3 > < div class = "evidence-placeholder" > < div > < strong > 假数据证据占位< / strong > < br > 真实可读字段必须等待 T-103 真机取证;不展示地址、手机号或付款信息。< / div > < / div > < / section >
< / div >
< footer class = "drawer-actions" > < button class = "button button-secondary" id = "drawer-close-secondary" type = "button" > 关闭< / button > < a class = "button button-primary" id = "full-detail-link" href = "web-task-detail.html" > 在完整页面打开< / a > < / footer >
< / dialog >
< script >
( ( ) => {
'use strict' ;
const keyword = document . querySelector ( '#keyword' ) ;
const laneFilter = document . querySelector ( '#lane-filter' ) ;
const timeFilter = document . querySelector ( '#time-filter' ) ;
const lanes = document . querySelector ( '#lanes' ) ;
const tasks = [
{ id : 'T-20260803-023' , title : '仓库雨衣补货' , productUrl : 'https://mobile.yangkeduo.invalid/goods.html?goods_id=900000023' , color : '荧光黄' , size : 'XXL' , maxPrice : '96.00' , quantity : 3 , status : 'DRAFT' , createdAt : '2026-08-03T14:42:00+08:00' , result : null } ,
{ id : 'T-20260803-022' , title : '8 月仓库工作服补货' , productUrl : 'https://mobile.yangkeduo.invalid/goods.html?goods_id=900000022' , color : '白色' , size : 'XL' , maxPrice : '80.00' , quantity : 2 , status : 'WAITING_CONFIRMATION' , createdAt : '2026-08-03T13:18:00+08:00' , result : { phase : '试选' , color : '白色' , size : 'XL' , unitPrice : '32.50' , quantity : 2 } } ,
{ id : 'T-20260803-021' , title : '办公室收纳盒' , productUrl : 'https://mobile.yangkeduo.invalid/goods.html?goods_id=900000021' , color : '透明' , size : 'L' , maxPrice : '60.00' , quantity : 2 , status : 'RECONCILIATION_REQUIRED' , createdAt : '2026-08-03T11:36:00+08:00' , result : { phase : '下单待核查' , color : '透明' , size : 'L' , unitPrice : '24.00' , quantity : 2 } } ,
{ id : 'T-20260803-020' , title : '仓库防滑手套' , productUrl : 'https://mobile.yangkeduo.invalid/goods.html?goods_id=900000020' , color : '黑色' , size : '均码' , maxPrice : '64.00' , quantity : 5 , status : 'PENDING' , createdAt : '2026-08-03T10:45:00+08:00' , result : null } ,
{ id : 'T-20260801-009' , title : '标签打印纸补货' , productUrl : 'https://mobile.yangkeduo.invalid/goods.html?goods_id=900000009' , color : '白色' , size : '50× 30' , maxPrice : '50.00' , quantity : 1 , status : 'WAITING_PAYMENT' , createdAt : '2026-08-01T16:12:00+08:00' , result : { phase : '已下单' , color : '白色' , size : '50× 30' , unitPrice : '46.00' , quantity : 1 } } ,
{ id : 'T-20260731-042' , title : '打包胶带补货' , productUrl : 'https://mobile.yangkeduo.invalid/goods.html?goods_id=900000042' , color : '透明' , size : '45mm' , maxPrice : '55.00' , quantity : 4 , status : 'SUCCEEDED' , createdAt : '2026-07-31T09:20:00+08:00' , result : { phase : '已下单' , color : '透明' , size : '45mm' , unitPrice : '12.00' , quantity : 4 } }
] ;
const statusMeta = {
DRAFT : { label : '待开始' , tone : 'neutral' , next : '勾选后点击“开始试选”,任务才会进入设备领取队列。' } ,
PENDING : { label : '待领取' , tone : '' , next : '已进入第一趟试选队列,等待桌面端轮询领取。' } ,
WAITING _CONFIRMATION : { label : '等待确认' , tone : '' , next : '查看规格面板证据,确认机器选对后才可签发下单授权。' } ,
RECONCILIATION _REQUIRED : { label : '结果待核查' , tone : 'warning' , next : '订单可能已创建。只核查同一提交记录,不得重新提交。' } ,
WAITING _PAYMENT : { label : '待付款' , tone : 'warning' , next : '人在拼多多核对商品、规格、数量和金额后手工付款;系统不会付款。' } ,
SUCCEEDED : { label : '已完成' , tone : 'success' , next : '已由人工付款并标记完成,可查看执行证据与审计记录。' }
} ;
const rows = document . querySelector ( '#task-rows' ) ;
const tableScroll = document . querySelector ( '#table-scroll' ) ;
const summary = document . querySelector ( '#result-summary' ) ;
const states = { loading : document . querySelector ( '#loading-state' ) , empty : document . querySelector ( '#empty-state' ) , error : document . querySelector ( '#error-state' ) } ;
const cards = [ ... document . querySelectorAll ( '[data-task] ' ) ] ;
const keyword = document . querySelector ( '#keyword' ) ;
const statusFilter = document . querySelector ( '#status-filter ' ) ;
const timeFilter = document . querySelector ( '#time-filter' ) ;
const filterForm = document . querySelector ( '#filter-form' ) ;
const selectionMode = document . querySelector ( '#selection-mode' ) ;
const selectionCount = document . querySelector ( '#selection-count' ) ;
const selectAll = document . querySelector ( '#select-all' ) ;
const states = { loading : document . querySelector ( '#loading-state' ) , empty : document . querySelector ( '#empty-state' ) , error : document . querySelector ( '#error-state' ) } ;
const selectedIds = new Set ( ) ;
let appliedKeyword = '' ;
let appliedStatus = 'all' ;
let appliedTime = 'all' ;
let newTaskId = '' ;
let demoState = 'default' ;
let lastDetailTrigger = null ;
let toastTimer = null ;
function hideStates ( ) {
Object . values ( states ) . forEach ( ( state ) => state . classList . remove ( 'show' ) ) ;
lanes . hidden = false ;
function escapeHtml ( value ) {
return String ( value ) . replace ( /[&<>"']/g , ( character ) => ( { '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' } ) [ character ] ) ;
}
function applyFilters ( ) {
hideStates ( ) ;
const query = keyword . value . trim ( ) . toLowerCase ( ) ;
const time = timeFilter . value ;
const selectedLane = laneFilter . value ;
const counts = { decision : 0 , machine : 0 , done : 0 } ;
let shown = 0 ;
cards . forEach ( ( card ) => {
const matchesText = ! query || card . dataset . search . toLowerCase ( ) . includes ( query ) ;
const matchesTime = time === 'all' || card . dataset . date === time || ( time === 'week' && card . dataset . date === 'today' ) ;
const matchesBase = matchesText && matchesTime ;
if ( matchesBase ) counts [ card . dataset . laneName ] += 1 ;
const visible = matchesBase && ( selectedLane === 'all' || selectedLane === card . dataset . laneName ) ;
card . hidden = ! visible ;
if ( visible ) shown += 1 ;
function normalizeMoney ( value ) {
const parts = value . split ( '.' ) ;
const whole = parts [ 0 ] . replace ( /^0+(?=\d)/ , '' ) || '0' ;
const fraction = ` ${ parts [ 1 ] || '' } 00 ` . slice ( 0 , 2 ) ;
return ` ${ whole } . ${ fraction } ` ;
}
function formatDate ( value ) {
return new Intl . DateTimeFormat ( 'zh-CN' , { year : 'numeric' , month : '2-digit' , day : '2-digit' , hour : '2-digit' , minute : '2-digit' , hour12 : false } ) . format ( new Date ( value ) ) ;
}
function visibleTasks ( ) {
const now = new Date ( ) ;
return [ ... tasks ] . sort ( ( a , b ) => new Date ( b . createdAt ) - new Date ( a . createdAt ) ) . filter ( ( task ) => {
const matchesTitle = ! appliedKeyword || task . title . toLowerCase ( ) . includes ( appliedKeyword . toLowerCase ( ) ) ;
const matchesStatus = appliedStatus === 'all' || task . status === appliedStatus ;
const created = new Date ( task . createdAt ) ;
const matchesTime = appliedTime === 'all' || ( appliedTime === 'today' && created . toDateString ( ) === now . toDateString ( ) ) || ( appliedTime === 'week' && now - created <= 7 * 24 * 60 * 60 * 1000 ) ;
return matchesTitle && matchesStatus && matchesTime ;
} ) ;
Object . entries ( counts ) . forEach ( ( [ name , value ] ) => { document . querySelector ( ` #count- ${ name } ` ) . textContent = value ; } ) ;
document . querySelectorAll ( '.lane' ) . forEach ( ( lane ) => { lane . hidden = selectedLane !== 'all' && lane . dataset . lane !== selectedLane ; } ) ;
summary . textContent = ` 当前显示 ${ shown } 条;等你决定 ${ counts . decision } ,机器在跑 ${ counts . machine } ,已结束 ${ counts . done } 。 ` ;
if ( ! shown ) { lanes . hidden = true ; states . empty . classList . add ( 'show' ) ; }
}
function clearFilters ( ) {
function formatResult ( task ) {
if ( ! task . result ) return '<span class="result empty">—</span>' ;
const result = task . result ;
const readable = ` ${ result . phase } : ${ result . color } , ${ result . size } , ${ result . unitPrice } 元, ${ result . quantity } 件 ` ;
return ` <span class="result" aria-label=" ${ escapeHtml ( readable ) } "><strong> ${ escapeHtml ( result . phase ) } : </strong>${ escapeHtml ( result . color ) } | ${ escapeHtml ( result . size ) } | ¥ ${ escapeHtml ( result . unitPrice ) } | ${ result . quantity } 件</span> ` ;
}
function renderRows ( ) {
const visible = visibleTasks ( ) ;
const validIds = new Set ( tasks . filter ( ( task ) => task . status === 'DRAFT' ) . map ( ( task ) => task . id ) ) ;
[ ... selectedIds ] . forEach ( ( id ) => { if ( ! validIds . has ( id ) ) selectedIds . delete ( id ) ; } ) ;
rows . innerHTML = visible . map ( ( task ) => {
const selectable = task . status === 'DRAFT' ;
const checked = selectedIds . has ( task . id ) ;
const meta = statusMeta [ task . status ] || { label : task . status , tone : 'neutral' , next : '查看完整详情了解下一步。' } ;
return ` <tr tabindex="0" data-task-row=" ${ escapeHtml ( task . id ) } " data-selected=" ${ checked } " data-new=" ${ task . id === newTaskId } " aria-label="任务 ${ escapeHtml ( task . title ) } ,状态 ${ escapeHtml ( meta . label ) } 。双击或按 Enter 查看详情 ${ selectable ? ',按空格切换选择' : '' } ">
<td><input class="row-check" type="checkbox" data-select-task=" ${ escapeHtml ( task . id ) } " aria-label="选择任务 ${ escapeHtml ( task . title ) } " ${ checked ? 'checked' : '' } ${ selectable ? '' : ` disabled title="仅待开始任务可勾选;当前为 ${ escapeHtml ( meta . label ) } " ` } ></td>
<td><a class="product-link" href=" ${ escapeHtml ( task . productUrl ) } " target="_blank" rel="noopener noreferrer" aria-label="在新标签打开拼多多商品假链接: ${ escapeHtml ( task . title ) } "> ${ escapeHtml ( task . title ) } <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 5h5v5M19 5l-9 9M19 13v6H5V5h6"/></svg></a><a class="task-link" href="#/tasks/ ${ encodeURIComponent ( task . id ) } " data-detail-link=" ${ escapeHtml ( task . id ) } "> ${ escapeHtml ( task . id ) } · 查看任务详情</a></td>
<td> ${ escapeHtml ( task . color ) } </td><td> ${ escapeHtml ( task . size ) } </td><td class="money">¥ ${ escapeHtml ( task . maxPrice ) } </td><td class="quantity"> ${ task . quantity } </td><td> ${ formatResult ( task ) } </td><td><span class="status ${ meta . tone } "> ${ escapeHtml ( meta . label ) } </span></td><td class="created"><time datetime=" ${ escapeHtml ( task . createdAt ) } "> ${ escapeHtml ( formatDate ( task . createdAt ) ) } </time></td>
</tr> ` ;
} ) . join ( '' ) ;
tableScroll . hidden = demoState !== 'default' || visible . length === 0 ;
Object . values ( states ) . forEach ( ( state ) => state . classList . remove ( 'show' ) ) ;
if ( demoState !== 'default' ) states [ demoState ] . classList . add ( 'show' ) ;
else if ( ! visible . length ) states . empty . classList . add ( 'show' ) ;
summary . textContent = demoState === 'loading' ? '正在加载任务。' : demoState === 'error' ? '任务加载失败,筛选条件已保留。' : demoState === 'empty' ? '当前演示空结果。' : ` 共 ${ visible . length } 条任务,按创建时间倒序;其中 ${ visible . filter ( ( task ) => task . status === 'DRAFT' ) . length } 条待开始。 ` ;
updateSelectionUi ( ) ;
if ( newTaskId ) window . setTimeout ( ( ) => { newTaskId = '' ; } , 1200 ) ;
}
function updateSelectionUi ( ) {
const count = selectedIds . size ;
filterForm . hidden = count > 0 ;
selectionMode . hidden = count === 0 ;
selectionCount . textContent = ` 已选择 ${ count } 条待开始任务 ` ;
const selectableVisible = visibleTasks ( ) . filter ( ( task ) => task . status === 'DRAFT' ) ;
const selectedVisible = selectableVisible . filter ( ( task ) => selectedIds . has ( task . id ) ) ;
selectAll . checked = selectableVisible . length > 0 && selectedVisible . length === selectableVisible . length ;
selectAll . indeterminate = selectedVisible . length > 0 && selectedVisible . length < selectableVisible . length ;
selectAll . disabled = selectableVisible . length === 0 ;
}
function showToast ( message ) {
const toast = document . querySelector ( '#toast' ) ;
window . clearTimeout ( toastTimer ) ;
toast . textContent = message ;
toast . hidden = false ;
toastTimer = window . setTimeout ( ( ) => { toast . hidden = true ; } , 4500 ) ;
}
function showDemo ( state ) {
demoState = state ;
selectedIds . clear ( ) ;
document . querySelector ( '.prototype-controls' ) . open = false ;
renderRows ( ) ;
}
filterForm . addEventListener ( 'submit' , ( event ) => {
event . preventDefault ( ) ;
appliedKeyword = keyword . value . trim ( ) ;
appliedStatus = statusFilter . value ;
appliedTime = timeFilter . value ;
selectedIds . clear ( ) ;
demoState = 'default' ;
document . querySelector ( '#advanced-filters' ) . open = false ;
renderRows ( ) ;
} ) ;
function clearFilter ( ) {
keyword . value = '' ;
laneFilter . value = 'all' ;
statusFilter . value = 'all' ;
timeFilter . value = 'all' ;
applyFilters ( ) ;
appliedKeyword = '' ;
appliedStatus = 'all' ;
appliedTime = 'all' ;
selectedIds . clear ( ) ;
demoState = 'default' ;
renderRows ( ) ;
keyword . focus ( ) ;
}
document . querySelector ( '#clear-filter' ) . addEventListener ( 'click' , clearFilter ) ;
document . querySelectorAll ( '[data-action="clear-filter"]' ) . forEach ( ( button ) => button . addEventListener ( 'click' , clearFilter ) ) ;
document . querySelectorAll ( '[data-action="retry"]' ) . forEach ( ( button ) => button . addEventListener ( 'click' , ( ) => showDemo ( 'default' ) ) ) ;
document . querySelectorAll ( '[data-demo]' ) . forEach ( ( button ) => button . addEventListener ( 'click' , ( ) => showDemo ( button . dataset . demo ) ) ) ;
function showDemo ( name ) {
if ( name === 'default' ) { applyFilters ( ) ; return ; }
lanes . hidden = true ;
Object . values ( states ) . forEach ( ( state ) => state . classList . remove ( 'show' ) ) ;
states [ name ] . classList . add ( 'show' ) ;
summary . textContent = name === 'loading' ? '正在加载任务。' : name === 'empty' ? '筛选结果为空。' : '任务加载失败,筛选条件已保留。' ;
selectAll . addEventListener ( 'change' , ( ) => {
visibleTasks ( ) . filter ( ( task ) => task . status === 'DRAFT' ) . forEach ( ( task ) => selectAll . checked ? selectedIds . add ( task . id ) : selectedIds . delete ( task . id ) ) ;
renderRows ( ) ;
} ) ;
rows . addEventListener ( 'change' , ( event ) => {
const checkbox = event . target . closest ( '[data-select-task]' ) ;
if ( ! checkbox ) return ;
checkbox . checked ? selectedIds . add ( checkbox . dataset . selectTask ) : selectedIds . delete ( checkbox . dataset . selectTask ) ;
renderRows ( ) ;
} ) ;
document . querySelector ( '#clear-selection' ) . addEventListener ( 'click' , ( ) => { selectedIds . clear ( ) ; renderRows ( ) ; keyword . focus ( ) ; } ) ;
document . querySelector ( '#start-trial' ) . addEventListener ( 'click' , ( ) => {
const ids = [ ... selectedIds ] ;
tasks . forEach ( ( task ) => { if ( selectedIds . has ( task . id ) && task . status === 'DRAFT' ) task . status = 'PENDING' ; } ) ;
selectedIds . clear ( ) ;
renderRows ( ) ;
showToast ( ` 已将 ${ ids . length } 条任务加入第一趟试选队列。没有签发下单授权,也不会付款。 ` ) ;
} ) ;
function isInteractive ( target ) { return Boolean ( target . closest ( 'a,button,input,label,select,textarea,summary' ) ) ; }
rows . addEventListener ( 'dblclick' , ( event ) => {
if ( isInteractive ( event . target ) ) return ;
const row = event . target . closest ( '[data-task-row]' ) ;
if ( row ) openDetail ( row . dataset . taskRow , row , true ) ;
} ) ;
rows . addEventListener ( 'keydown' , ( event ) => {
const row = event . target . closest ( '[data-task-row]' ) ;
if ( ! row || event . target !== row ) return ;
if ( event . key === 'Enter' ) { event . preventDefault ( ) ; openDetail ( row . dataset . taskRow , row , true ) ; }
if ( event . key === ' ' ) {
const task = tasks . find ( ( item ) => item . id === row . dataset . taskRow ) ;
if ( ! task || task . status !== 'DRAFT' ) return ;
event . preventDefault ( ) ;
selectedIds . has ( task . id ) ? selectedIds . delete ( task . id ) : selectedIds . add ( task . id ) ;
renderRows ( ) ;
document . querySelector ( ` [data-task-row=" ${ CSS . escape ( task . id ) } "] ` ) ? . focus ( ) ;
}
} ) ;
rows . addEventListener ( 'click' , ( event ) => {
const link = event . target . closest ( '[data-detail-link]' ) ;
if ( ! link || event . button !== 0 || event . ctrlKey || event . metaKey || event . shiftKey || event . altKey ) return ;
event . preventDefault ( ) ;
openDetail ( link . dataset . detailLink , link , true ) ;
} ) ;
const createDialog = document . querySelector ( '#create-dialog' ) ;
const createForm = document . querySelector ( '#create-form' ) ;
const createFields = {
title : document . querySelector ( '#create-name' ) , url : document . querySelector ( '#create-url' ) , color : document . querySelector ( '#create-color' ) , size : document . querySelector ( '#create-size' ) , price : document . querySelector ( '#create-price' ) , quantity : document . querySelector ( '#create-quantity' )
} ;
const createErrors = Object . fromEntries ( Object . keys ( createFields ) . map ( ( name ) => [ name , document . querySelector ( ` #create- ${ name === 'title' ? 'name' : name } -error ` ) ] ) ) ;
const createSummary = document . querySelector ( '#create-error-summary' ) ;
function clearCreateErrors ( ) {
Object . values ( createFields ) . forEach ( ( field ) => field . removeAttribute ( 'aria-invalid' ) ) ;
Object . values ( createErrors ) . forEach ( ( node ) => { node . textContent = '' ; } ) ;
createSummary . classList . remove ( 'show' ) ;
createSummary . textContent = '' ;
}
keyword . addEventListener ( 'input' , applyFilters ) ;
laneFilter . addEventListener ( 'change' , applyFilters ) ;
timeFilter . addEventListener ( 'change' , applyFilters ) ;
document . querySelector ( '#clear-filters' ) . addEventListener ( 'click' , clearFilters ) ;
document . querySelectorAll ( '[data-action="clear"]' ) . forEach ( ( button ) => button . addEventListener ( 'click' , clearFilters ) ) ;
document . querySelectorAll ( '[data-action="retry"]' ) . forEach ( ( button ) => button . addEventListener ( 'click' , applyFilters ) ) ;
document . querySelectorAll ( '[data-demo]' ) . forEach ( ( button ) => button . addEventListener ( 'click' , ( ) => showDemo ( button . dataset . demo ) ) ) ;
applyFilters ( ) ;
function showCreateErrors ( messages ) {
clearCreateErrors ( ) ;
Object . entries ( messages ) . forEach ( ( [ name , message ] ) => { createFields [ name ] . setAttribute ( 'aria-invalid' , 'true' ) ; createErrors [ name ] . textContent = message ; } ) ;
createSummary . textContent = ` 无法保存任务:请修正 ${ Object . keys ( messages ) . length } 个字段。 ` ;
createSummary . classList . add ( 'show' ) ;
createSummary . focus ( ) ;
}
document . querySelector ( '#open-create' ) . addEventListener ( 'click' , ( ) => { clearCreateErrors ( ) ; createDialog . showModal ( ) ; createFields . title . focus ( ) ; } ) ;
document . querySelectorAll ( '[data-close-create]' ) . forEach ( ( button ) => button . addEventListener ( 'click' , ( ) => createDialog . close ( ) ) ) ;
createForm . addEventListener ( 'submit' , ( event ) => {
event . preventDefault ( ) ;
const messages = { } ;
if ( ! createFields . title . value . trim ( ) ) messages . title = '请输入任务标题。' ;
if ( ! /^https:\/\/[^\s]+[?&]goods_id=\d+/i . test ( createFields . url . value . trim ( ) ) ) messages . url = '请输入能解析出 goods_id 的 HTTPS 商品链接。' ;
if ( ! createFields . color . value . trim ( ) ) messages . color = '请输入颜色。' ;
if ( ! createFields . size . value . trim ( ) ) messages . size = '请输入尺码。' ;
if ( ! /^\d+(\.\d{1,2})?$/ . test ( createFields . price . value . trim ( ) ) || /^0+(\.0{1,2})?$/ . test ( createFields . price . value . trim ( ) ) ) messages . price = '请输入大于 0、最多两位小数的价格上限。' ;
if ( ! /^\d+$/ . test ( createFields . quantity . value ) || parseInt ( createFields . quantity . value , 10 ) < 1 ) messages . quantity = '数量必须是大于 0 的整数。' ;
if ( Object . keys ( messages ) . length ) { showCreateErrors ( messages ) ; return ; }
const now = new Date ( ) ;
const datePart = ` ${ now . getFullYear ( ) } ${ String ( now . getMonth ( ) + 1 ) . padStart ( 2 , '0' ) } ${ String ( now . getDate ( ) ) . padStart ( 2 , '0' ) } ` ;
const id = ` T- ${ datePart } - ${ String ( tasks . length + 24 ) . padStart ( 3 , '0' ) } ` ;
tasks . push ( { id , title : createFields . title . value . trim ( ) , productUrl : createFields . url . value . trim ( ) , color : createFields . color . value . trim ( ) , size : createFields . size . value . trim ( ) , maxPrice : normalizeMoney ( createFields . price . value . trim ( ) ) , quantity : parseInt ( createFields . quantity . value , 10 ) , status : 'DRAFT' , createdAt : now . toISOString ( ) , result : null } ) ;
newTaskId = id ;
appliedKeyword = '' ;
keyword . value = '' ;
statusFilter . value = 'all' ;
timeFilter . value = 'all' ;
appliedStatus = 'all' ;
appliedTime = 'all' ;
selectedIds . clear ( ) ;
demoState = 'default' ;
createDialog . close ( ) ;
renderRows ( ) ;
const newRow = document . querySelector ( ` [data-task-row=" ${ CSS . escape ( id ) } "] ` ) ;
newRow ? . focus ( ) ;
showToast ( ` 任务 ${ id } 已保存为待开始,并按创建时间显示在第一行。 ` ) ;
} ) ;
const drawer = document . querySelector ( '#detail-drawer' ) ;
const drawerTitle = document . querySelector ( '#drawer-title' ) ;
const drawerId = document . querySelector ( '#drawer-id' ) ;
const drawerRequirements = document . querySelector ( '#drawer-requirements' ) ;
const drawerResult = document . querySelector ( '#drawer-result' ) ;
const drawerNext = document . querySelector ( '#drawer-next-step' ) ;
const fullDetailLink = document . querySelector ( '#full-detail-link' ) ;
function routeTaskId ( ) {
const match = window . location . hash . match ( /^#\/tasks\/(.+)$/ ) ;
return match ? decodeURIComponent ( match [ 1 ] ) : null ;
}
function renderDrawer ( task ) {
const meta = statusMeta [ task . status ] || { label : task . status , tone : 'neutral' , next : '查看完整详情了解下一步。' } ;
drawerId . textContent = ` ${ task . id } · ${ meta . label } ` ;
drawerTitle . textContent = task . title ;
drawerRequirements . innerHTML = ` <div><dt>颜色</dt><dd> ${ escapeHtml ( task . color ) } </dd></div><div><dt>尺码</dt><dd> ${ escapeHtml ( task . size ) } </dd></div><div><dt>价格上限</dt><dd>¥ ${ escapeHtml ( task . maxPrice ) } </dd></div><div><dt>数量</dt><dd> ${ task . quantity } 件</dd></div><div><dt>状态</dt><dd><span class="status ${ meta . tone } "> ${ escapeHtml ( meta . label ) } </span></dd></div><div><dt>创建时间</dt><dd> ${ escapeHtml ( formatDate ( task . createdAt ) ) } </dd></div> ` ;
drawerResult . innerHTML = task . result ? ` <p style="margin:0 0 10px"> ${ formatResult ( task ) } </p><p style="margin:0;color:var(--muted);font-size:.82rem">展示值来自结构化颜色、尺码、单价和数量;“|”只用于页面格式化。</p> ` : '<p class="result empty" style="margin:0">尚无采购结果。任务未完成试选或下单,不用其他数据填充。</p>' ;
drawerNext . textContent = meta . next ;
fullDetailLink . href = ` web-task-detail.html# ${ encodeURIComponent ( task . id ) } ` ;
}
function openDetail ( id , trigger , pushRoute ) {
const task = tasks . find ( ( item ) => item . id === id ) ;
if ( ! task ) { showToast ( '没有找到该任务,列表可能已经刷新。' ) ; return ; }
if ( trigger ) lastDetailTrigger = trigger . closest ? . ( '[data-task-row]' ) || trigger ;
renderDrawer ( task ) ;
if ( ! drawer . open ) drawer . showModal ( ) ;
if ( pushRoute && routeTaskId ( ) !== id ) history . pushState ( { taskDrawer : true , taskId : id } , '' , ` #/tasks/ ${ encodeURIComponent ( id ) } ` ) ;
window . setTimeout ( ( ) => drawerTitle . focus ( ) , 0 ) ;
}
function closeDrawerPhysical ( ) {
if ( drawer . open ) drawer . close ( ) ;
const focusTarget = lastDetailTrigger ;
lastDetailTrigger = null ;
window . setTimeout ( ( ) => focusTarget ? . focus ? . ( ) , 0 ) ;
}
function requestDrawerClose ( ) {
if ( history . state ? . taskDrawer && routeTaskId ( ) ) history . back ( ) ;
else {
history . replaceState ( { } , '' , ` ${ location . pathname } ${ location . search } ` ) ;
closeDrawerPhysical ( ) ;
}
}
function syncDrawerRoute ( ) {
const id = routeTaskId ( ) ;
if ( id ) openDetail ( id , null , false ) ;
else closeDrawerPhysical ( ) ;
}
drawer . addEventListener ( 'cancel' , ( event ) => { event . preventDefault ( ) ; requestDrawerClose ( ) ; } ) ;
document . querySelector ( '#close-drawer' ) . addEventListener ( 'click' , requestDrawerClose ) ;
document . querySelector ( '#drawer-close-secondary' ) . addEventListener ( 'click' , requestDrawerClose ) ;
window . addEventListener ( 'popstate' , syncDrawerRoute ) ;
window . addEventListener ( 'hashchange' , syncDrawerRoute ) ;
renderRows ( ) ;
syncDrawerRoute ( ) ;
} ) ( ) ;
< / script >
< / body >