2026-08-03 12:35:54 +08:00
<!doctype html>
< html lang = "zh-CN" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
2026-08-03 12:44:59 +08:00
< link rel = "icon" href = "data:," >
2026-08-03 16:05:05 +08:00
< title > 采购任务 · 采购服务原型</ title >
2026-08-03 12:35:54 +08:00
< style >
: root {
2026-08-03 15:00:49 +08:00
--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 ;
2026-08-03 12:35:54 +08:00
}
2026-08-03 15:00:49 +08:00
* { 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 : #29466f ; 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 : #eef2f7 ; }
. 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 = "true" ] { 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 % ; }
2026-08-03 12:35:54 +08:00
}
2026-08-03 15:00:49 +08:00
@ 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 ; }
2026-08-03 12:35:54 +08:00
}
2026-08-03 15:00:49 +08:00
@ 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 ; }
2026-08-03 12:35:54 +08:00
}
</ style >
</ head >
< body >
< a class = "skip-link" href = "#main" > 跳到主要内容</ a >
< div class = "prototype-banner" > PROTOTYPE - 仅供枚举交互,非实现依据</ div >
< header class = "app-header" >
2026-08-03 16:05:05 +08:00
< a class = "brand" href = "web-task-workbench.html" aria-label = "采购服务原型工作台" >< span class = "brand-mark" aria-hidden = "true" > 采</ span >< span > 采购服务</ span ></ a >
2026-08-03 15:00:49 +08:00
< nav class = "primary" aria-label = "主导航" >< a href = "web-task-workbench.html" aria-current = "page" > 采购任务</ a >< a href = "web-task-create.html" > 建单直达页</ a ></ nav >
2026-08-03 12:35:54 +08:00
< span class = "header-account" > 采购管理员 · caigou_admin</ span >
</ header >
< main id = "main" >
< header class = "page-head" >
2026-08-04 16:25:34 +08:00
< div >< h1 > 采购任务</ h1 >< p class = "subtitle" > 创建后先保存为待开始;勾选并开始采购后,采购工具按授权逐条执行。</ p ></ div >
< p class = "safety-note" >< strong > 开始采购会授权创建待付款订单。</ strong > 系统校验规格、数量和金额后最多提交一次,任何时候都不会自动付款。</ p >
2026-08-03 12:35:54 +08:00
</ header >
2026-08-03 15:00:49 +08:00
< 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 >
2026-08-03 12:35:54 +08:00
</ div >
2026-08-03 15:00:49 +08:00
< 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" >
2026-08-04 16:25:34 +08:00
< 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 = "ORDERING" > 采购执行中</ option >< option value = "NEEDS_MANUAL" > 待人工处理</ 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 >
2026-08-03 15:00:49 +08:00
< 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 >
2026-08-04 16:25:34 +08:00
< div class = "selection-copy" >< strong id = "selection-count" > 已选择 0 条</ strong >< span id = "selection-cap" > 最高总额合计 ¥0.00</ span >< span > 点击将授权采购工具逐条创建待付款订单;系统不会付款。</ span ></ div >
< div class = "toolbar-group" >< button class = "button button-primary" id = "start-purchase" type = "button" > 开始采购(只创建待付款订单)</ button >< button class = "button button-secondary" id = "clear-selection" type = "button" > 清除选择</ button ></ div >
2026-08-03 15:00:49 +08:00
</ div >
2026-08-03 12:35:54 +08:00
</ div >
2026-08-03 15:00:49 +08:00
</ div >
2026-08-03 12:35:54 +08:00
2026-08-03 15:00:49 +08:00
< div class = "table-summary" >
< span id = "result-summary" 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 >
2026-08-03 12:35:54 +08:00
</ section >
</ main >
2026-08-03 15:00:49 +08:00
< 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 >
2026-08-04 16:25:34 +08:00
< section class = "drawer-panel" >< h3 > 执行证据</ h3 >< div class = "evidence-placeholder" >< div >< strong > 内部截图证据占位</ strong >< br > 真实字段等待真机取证;完整 XML 与支付凭据不会进入采购服务。</ div ></ div ></ section >
2026-08-03 15:00:49 +08:00
</ 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 >
2026-08-03 12:35:54 +08:00
< script >
(() => {
'use strict' ;
2026-08-03 15:00:49 +08:00
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 },
2026-08-04 16:25:34 +08:00
{ 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 : 'ORDERING' , createdAt : '2026-08-03T13:18:00+08:00' , result : { phase : '采购执行' , color : '白色' , size : 'XL' , unitPrice : '32.50' , quantity : 2 } },
2026-08-03 15:00:49 +08:00
{ 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 = {
2026-08-04 16:25:34 +08:00
DRAFT : { label : '待开始' , tone : 'neutral' , next : '勾选后点击“开始采购(只创建待付款订单)”,才会签发一次性授权。' },
PENDING : { label : '已授权待领取' , tone : '' , next : '管理员已授权创建待付款订单,等待采购工具领取;系统不会付款。' },
ORDERING : { label : '采购执行中' , tone : '' , next : '采购工具正在同一趟校验规格、价格和数量;无需中途确认。' },
NEEDS_MANUAL : { label : '待人工处理' , tone : 'warning' , next : '围栏前执行已停止。核查原因后可重置为待开始或取消。' },
2026-08-03 15:00:49 +08:00
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' );
2026-08-03 12:35:54 +08:00
const summary = document . querySelector ( '#result-summary' );
2026-08-03 15:00:49 +08:00
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' );
2026-08-04 16:25:34 +08:00
const selectionCap = document . querySelector ( '#selection-cap' );
2026-08-03 15:00:49 +08:00
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 ;
2026-08-03 12:35:54 +08:00
2026-08-03 15:00:49 +08:00
function escapeHtml ( value ) {
return String ( value ). replace ( /[&<>"']/g , ( character ) => ({ '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' })[ character ]);
2026-08-03 12:35:54 +08:00
}
2026-08-03 15:00:49 +08:00
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 } ` ;
}
2026-08-04 16:25:34 +08:00
function moneyToCents ( value ) {
const normalized = normalizeMoney ( value );
const [ whole , fraction ] = normalized . split ( '.' );
return Number . parseInt ( whole , 10 ) * 100 + Number . parseInt ( fraction , 10 );
}
2026-08-03 15:00:49 +08:00
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 ;
2026-08-03 12:35:54 +08:00
});
}
2026-08-03 15:00:49 +08:00
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 } 条待开始任务` ;
2026-08-04 16:25:34 +08:00
const capInCents = tasks . filter (( task ) => selectedIds . has ( task . id )). reduce (( sum , task ) => sum + moneyToCents ( task . maxPrice ), 0 );
selectionCap . textContent = `最高总额合计 ¥ ${ ( capInCents / 100 ). toFixed ( 2 ) } ` ;
2026-08-03 15:00:49 +08:00
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 () {
2026-08-03 12:35:54 +08:00
keyword . value = '' ;
2026-08-03 15:00:49 +08:00
statusFilter . value = 'all' ;
2026-08-03 12:35:54 +08:00
timeFilter . value = 'all' ;
2026-08-03 15:00:49 +08:00
appliedKeyword = '' ;
appliedStatus = 'all' ;
appliedTime = 'all' ;
selectedIds . clear ();
demoState = 'default' ;
renderRows ();
2026-08-03 12:35:54 +08:00
keyword . focus ();
}
2026-08-03 15:00:49 +08:00
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 )));
2026-08-03 12:35:54 +08:00
2026-08-03 15:00:49 +08:00
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 (); });
2026-08-04 16:25:34 +08:00
document . querySelector ( '#start-purchase' ). addEventListener ( 'click' , () => {
2026-08-03 15:00:49 +08:00
const ids = [... selectedIds ];
tasks . forEach (( task ) => { if ( selectedIds . has ( task . id ) && task . status === 'DRAFT' ) task . status = 'PENDING' ; });
selectedIds . clear ();
renderRows ();
2026-08-04 16:25:34 +08:00
showToast ( `已授权 ${ ids . length } 条任务创建待付款订单。采购工具将逐条执行,系统不会付款。` );
2026-08-03 15:00:49 +08:00
});
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 = '' ;
2026-08-03 12:35:54 +08:00
}
2026-08-03 15:00:49 +08:00
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>` ;
2026-08-04 16:25:34 +08:00
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>' ;
2026-08-03 15:00:49 +08:00
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 ();
2026-08-03 12:35:54 +08:00
})();
</ script >
</ body >
</ html >