27 lines
1.5 MiB
27 lines
1.5 MiB
/* This file is generated by scripts/generate-exact-html.mjs. Do not edit by hand. */
|
||
export type ExactHtmlKey = "account" | "assetFactory" | "dashboard" | "imageOptimize" | "library" | "login" | "messages" | "modelPhoto" | "modelPhotoDemoA" | "modelPhotoDemoB" | "pipeline" | "platformCover" | "productCreate" | "productCreateUpload" | "productDetail" | "products" | "projectWizard" | "projects" | "register" | "settings" | "team";
|
||
|
||
export const exactHtmlDocuments: Record<ExactHtmlKey, string> = {
|
||
"account": "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta name=\"x-airshelf-exact-source\" content=\"account.html\">\n<meta charset=\"utf-8\">\n<title>消费 · Airshelf</title>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"/exact/assets/restraint.css?v=2026060101\">\n<style>\n /* ─── 顶部:左右布局(余额 banner + 快速充值)─── */\n .top-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(480px, .85fr); gap: 16px; margin-bottom: 22px; align-items: stretch; }\n @media (max-width: 1120px) { .top-grid { grid-template-columns: 1fr; } }\n\n .balance-banner {\n background: var(--accent-black);\n color: var(--accent-white);\n padding: 26px 28px;\n position: relative;\n border: 1px solid var(--accent-black);\n border-radius: var(--r-md);\n display: flex;\n flex-direction: column;\n gap: 24px;\n min-width: 0;\n min-height: 246px;\n }\n .balance-banner::before, .balance-banner::after,\n .balance-banner > .corner-tr, .balance-banner > .corner-bl {\n content: ''; position: absolute; width: 14px; height: 14px;\n background: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 21' fill='%23e8e8e8'%3E%3Cpath d='M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z'/%3E%3C/svg%3E\") no-repeat center;\n background-size: contain; pointer-events: none;\n }\n .balance-banner::before { top: -7px; left: -7px; }\n .balance-banner::after { bottom: -7px; right: -7px; }\n .balance-banner > .corner-tr { top: -7px; right: -7px; }\n .balance-banner > .corner-bl { bottom: -7px; left: -7px; }\n\n /* 主余额 · 突出展示 */\n .balance-hero { display: flex; flex-direction: column; gap: 4px; }\n .balance-hero .lbl { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.55); letter-spacing: .06em; text-transform: uppercase; }\n .balance-hero .v { font-size: 38px; font-weight: 700; letter-spacing: -.02em; font-variant-numeric: tabular-nums; line-height: 1.1; }\n .balance-hero .meta { font-size: 11.5px; color: rgba(255,255,255,.5); font-family: var(--font-mono); letter-spacing: .02em; margin-top: 4px; }\n\n /* 子统计 · 月限额 / 已用 */\n .balance-sub { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; padding: 16px 0 0; border-top: 1px solid rgba(255,255,255,.1); }\n .balance-sub .col { min-width: 0; }\n .balance-sub .lbl { font-family: var(--font-mono); font-size: 10px; color: rgba(255,255,255,.5); letter-spacing: .06em; text-transform: uppercase; }\n .balance-sub .v { font-size: 18px; font-weight: 700; letter-spacing: -.01em; margin-top: 4px; font-variant-numeric: tabular-nums; }\n .balance-sub .meta { font-size: 10.5px; color: rgba(255,255,255,.42); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }\n\n .balance-foot { margin-top: auto; padding-top: 2px; }\n .balance-meter { height: 5px; background: rgba(255,255,255,.12); border-radius: var(--r-pill); overflow: hidden; }\n .balance-meter > span { display: block; height: 100%; width: 5.4%; background: var(--heat); border-radius: inherit; }\n .balance-foot-meta { display: flex; justify-content: space-between; gap: 14px; margin-top: 8px; color: rgba(255,255,255,.46); font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .02em; }\n\n /* ─── 快速充值 pane(右栏)─── */\n .pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 20px; margin-bottom: 16px; }\n .pane h3 { font-size: 14px; font-weight: 600; margin-bottom: 6px; }\n .pane .desc { font-size: 11.5px; color: var(--black-alpha-48); margin-bottom: 14px; font-family: var(--font-mono); letter-spacing: .02em; }\n .topup-pane { display: flex; flex-direction: column; padding: 20px 22px; margin-bottom: 0; min-height: 246px; }\n .topup-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 14px; }\n .topup-head h3 { margin-bottom: 5px; }\n .topup-head .desc { margin-bottom: 0; }\n .topup-selected { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); white-space: nowrap; padding-top: 2px; }\n\n .recharge-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }\n .recharge-card { min-height: 76px; border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 10px 8px; text-align: center; cursor: pointer; background: var(--surface); position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; transition: background var(--t-base), border-color var(--t-base), box-shadow var(--t-base), transform var(--t-fast); }\n .recharge-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-24); }\n .recharge-card:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--heat-40); }\n .recharge-card.selected { border-color: var(--heat); background: var(--heat-12); box-shadow: inset 0 0 0 1px var(--heat); }\n .recharge-card.selected::after { content: '✓'; position: absolute; top: 6px; right: 7px; width: 15px; height: 15px; border-radius: 50%; display: grid; place-items: center; background: var(--heat); color: var(--accent-white); font-size: 10px; line-height: 1; }\n .recharge-card .amt { font-size: 17px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.15; }\n .recharge-card .gift { font-size: 10px; color: var(--black-alpha-48); margin-top: 4px; font-family: var(--font-mono); white-space: nowrap; }\n .recharge-card .gift.bonus { color: var(--accent-forest); font-weight: 600; }\n .recharge-card .ribbon { position: absolute; top: 6px; left: 7px; font-family: var(--font-mono); font-size: 9px; padding: 1px 5px; background: var(--heat); color: var(--accent-white); letter-spacing: .03em; font-weight: 600; border-radius: var(--r-sm); }\n .pay-row { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }\n .pay-title { font-size: 12px; font-weight: 600; color: var(--accent-black); line-height: 1.2; }\n .pay-row .input { width: 100%; box-sizing: border-box; height: 38px; }\n .pay-btn-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }\n .pay-method-btn { height: 38px; border-radius: var(--r-pill); display: inline-flex; justify-content: center; align-items: center; gap: 8px; background: var(--surface); color: var(--accent-black); border-color: var(--border-faint); font-weight: 500; }\n .pay-method-btn:hover { background: var(--background-lighter); color: var(--accent-black); border-color: var(--black-alpha-24); }\n .pay-method-btn:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--black-alpha-16); }\n .pay-logo { width: 18px; height: 18px; border-radius: 6px; display: inline-block; flex: 0 0 18px; overflow: hidden; }\n .pay-logo img { width: 100%; height: 100%; display: block; object-fit: cover; border-radius: inherit; }\n @media (max-width: 720px) {\n .recharge-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }\n }\n\n /* ─── Tab strip ─── */\n .billing-tabs { display: flex; align-items: flex-end; gap: 4px; border-bottom: 1px solid var(--border-faint); margin: 24px 0 18px; padding: 0 2px; overflow-x: auto; scrollbar-width: none; }\n .billing-tabs::-webkit-scrollbar { display: none; }\n .billing-tabs .tab { display: inline-flex; align-items: center; flex: 0 0 auto; gap: 6px; background: transparent; border: 0; border-bottom: 2px solid transparent; border-radius: var(--r-md) var(--r-md) 0 0; margin-bottom: -1px; padding: 10px 14px; font-size: 13px; font-weight: 500; color: var(--black-alpha-56); font-family: inherit; cursor: pointer; letter-spacing: 0; user-select: none; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }\n .billing-tabs .tab:hover { color: var(--accent-black); background: var(--black-alpha-4); }\n .billing-tabs .tab:focus-visible { outline: none; box-shadow: inset 0 0 0 1px var(--heat-40); }\n .billing-tabs .tab.active { color: var(--accent-black); border-bottom-color: var(--heat); font-weight: 600; }\n .billing-tabs .tab .count { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); padding: 1px 7px; background: var(--black-alpha-4); border-radius: var(--r-sm); letter-spacing: .04em; }\n .billing-tabs .tab.active .count { background: var(--heat-12); color: var(--heat); }\n\n .tab-panel { display: none; }\n .tab-panel.active { display: block; }\n\n /* ─── 总览 · 趋势 + 阶段分布 (两栏等高) ─── */\n .overview-grid { display: grid; grid-template-columns: 1.6fr 1fr; gap: 16px; align-items: stretch; }\n\n .trend-pane { padding: 18px 20px 14px; display: flex; flex-direction: column; }\n .trend-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 14px; }\n .trend-head h3 { margin-bottom: 0; }\n .trend-head .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .trend-head .spacer { flex: 1; }\n .trend-head .chip { font-family: var(--font-mono); font-size: 10.5px; padding: 3px 8px; border: 1px solid var(--border-faint); border-radius: var(--r-pill); color: var(--black-alpha-56); cursor: pointer; }\n .trend-head .chip.active { background: var(--accent-black); color: var(--accent-white); border-color: var(--accent-black); }\n\n .trend-chart { display: grid; grid-template-rows: 1fr auto; gap: 6px; min-height: 170px; flex: 1; padding: 6px 4px 2px; position: relative; }\n .trend-chart .bars { display: grid; grid-template-columns: repeat(14, 1fr); gap: 5px; align-items: end; height: 100%; }\n .trend-chart .bar { background: var(--background-lighter); border-radius: 2px 2px 0 0; position: relative; transition: background var(--t-base); cursor: pointer; }\n .trend-chart .bar > span { display: block; width: 100%; background: var(--heat); border-radius: 2px 2px 0 0; }\n .trend-chart .bar:hover > span { background: var(--accent-black); }\n .trend-chart .bar.peak > span { background: var(--accent-black); }\n .trend-chart .x-axis { display: grid; grid-template-columns: repeat(14, 1fr); gap: 5px; font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-32); text-align: center; letter-spacing: .02em; }\n .trend-foot { display: flex; gap: 14px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-faint); font-size: 12px; }\n .trend-foot .item { display: flex; align-items: baseline; gap: 6px; }\n .trend-foot .item .k { color: var(--black-alpha-48); font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .02em; }\n .trend-foot .item .v { font-variant-numeric: tabular-nums; font-weight: 600; color: var(--accent-black); }\n .trend-foot .item .v.warn { color: #B45309; }\n\n /* ─── 阶段分布 ─── */\n .stage-pane .usage-line { display: flex; justify-content: space-between; padding: 4px 0 4px; font-size: 12.5px; }\n .stage-pane .usage-line .k { color: var(--accent-black); }\n .stage-pane .usage-line .v { font-variant-numeric: tabular-nums; color: var(--accent-black); font-weight: 600; }\n .stage-pane .usage-bar { height: 4px; background: var(--background-lighter); border-radius: 2px; margin: 4px 0 10px; overflow: hidden; }\n .stage-pane .usage-bar > span { display: block; height: 100%; transition: width .3s ease; }\n .stage-pane .total { display: flex; justify-content: space-between; padding-top: 10px; margin-top: 6px; border-top: 1px solid var(--border-faint); font-size: 13px; font-weight: 600; }\n .stage-pane .total .v { font-variant-numeric: tabular-nums; }\n\n /* ─── 扣费规则 + 四层预检 ─── */\n .rule-pane .rule-list { font-size: 12.5px; color: var(--black-alpha-56); line-height: 1.7; }\n .rule-pane .rule-list strong { color: var(--accent-black); font-weight: 600; }\n .rule-pane .mono-acc { font-family: var(--font-mono); color: var(--heat); background: var(--heat-12); padding: 1px 5px; font-size: 11.5px; border-radius: var(--r-sm); }\n\n .quota-rules { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border-faint); }\n .quota-rules .qr-head { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; margin-bottom: 10px; }\n .quota-rules .step { display: grid; grid-template-columns: 22px 1fr; gap: 10px; align-items: baseline; margin-bottom: 6px; font-size: 12.5px; color: var(--accent-black); }\n .quota-rules .step .num { width: 20px; height: 20px; border-radius: 50%; background: var(--heat-12); color: var(--heat); font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; display: grid; place-items: center; }\n .quota-rules .step .formula { font-family: var(--font-mono); font-size: 11.5px; color: var(--heat); background: var(--heat-12); padding: 0 4px; border-radius: var(--r-sm); }\n\n /* ─── 表格通用 ─── */\n .billing-table { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--surface); border: 1px solid var(--border-muted); border-radius: var(--r-md); overflow: hidden; }\n .billing-table th, .billing-table td { padding: 11px 14px; text-align: left; font-size: 12.5px; border-bottom: 0; }\n .billing-table thead th { background: var(--background-lighter); border-bottom: 1px solid var(--border-muted); font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }\n .billing-table tbody tr:hover { background: var(--background-lighter); }\n .billing-table .ts { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .billing-table .neg { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--accent-black); text-align: right; }\n .billing-table .pos { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--accent-forest); text-align: right; }\n .billing-table .zero { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--black-alpha-32); text-align: right; }\n .billing-table .muted { color: var(--black-alpha-56); font-size: 11.5px; }\n .billing-table .ref { color: var(--black-alpha-48); font-size: 10.5px; font-family: var(--font-mono); }\n .billing-table .who { display: inline-flex; align-items: center; gap: 8px; }\n .billing-table .who .av { width: 24px; height: 24px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: inline-grid; place-items: center; font-size: 11px; font-weight: 600; color: var(--accent-black); }\n .billing-table .role-pill { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: var(--r-pill); font-size: 10.5px; font-weight: 500; }\n .billing-table .role-pill .dot { width: 5px; height: 5px; border-radius: 50%; }\n .billing-table .role-super { background: var(--heat-12); color: var(--heat); }\n .billing-table .role-super .dot { background: var(--heat); }\n .billing-table .role-admin { background: rgba(30,64,175,.1); color: #1E40AF; }\n .billing-table .role-admin .dot { background: #1E40AF; }\n .billing-table .role-member { background: var(--background-lighter); color: var(--black-alpha-56); }\n .billing-table .role-member .dot { background: var(--black-alpha-56); }\n .billing-table .status-tag { font-family: var(--font-mono); font-size: 10px; padding: 1px 6px; border-radius: var(--r-sm); letter-spacing: .04em; }\n .billing-table .status-tag.ok { background: rgba(66,195,102,.12); color: var(--accent-forest); }\n .billing-table .status-tag.wip { background: var(--heat-12); color: var(--heat); }\n .billing-table .status-tag.fail { background: rgba(235,52,36,.10); color: var(--accent-crimson); }\n .billing-table .progress-mini { width: 80px; height: 4px; background: var(--background-lighter); border-radius: 2px; overflow: hidden; display: inline-block; vertical-align: middle; margin-left: 8px; }\n .billing-table .progress-mini > span { display: block; height: 100%; background: var(--heat); }\n\n /* ─── 流水筛选条 ─── */\n .filter-bar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }\n .filter-bar select, .filter-bar input { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 6px 10px; font-size: 12.5px; font-family: inherit; color: var(--accent-black); }\n .filter-bar select { padding-right: 24px; }\n .filter-bar .spacer { flex: 1; }\n .filter-bar .ct { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }\n\n /* 充值 modal */\n .topup-modal { width: min(460px, 92vw); }\n .topup-modal .topup-qr { aspect-ratio: 1; max-width: 220px; margin: 4px auto 12px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); display: grid; place-items: center; position: relative; overflow: hidden; }\n .topup-modal .topup-qr::before {\n content: ''; position: absolute; inset: 18px;\n background-image: linear-gradient(45deg, var(--accent-black) 25%, transparent 25%),\n linear-gradient(-45deg, var(--accent-black) 25%, transparent 25%),\n linear-gradient(45deg, transparent 75%, var(--accent-black) 75%),\n linear-gradient(-45deg, transparent 75%, var(--accent-black) 75%);\n background-size: 16px 16px;\n background-position: 0 0, 0 8px, 8px -8px, -8px 0px;\n opacity: .14;\n }\n .topup-modal .topup-qr .center { position: relative; z-index: 1; background: var(--surface); padding: 10px 12px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-56); letter-spacing: .02em; text-align: center; }\n .topup-modal .topup-info { text-align: center; font-size: 13px; color: var(--black-alpha-56); margin-bottom: 6px; }\n .topup-modal .topup-amt { text-align: center; font-size: 26px; font-weight: 700; font-variant-numeric: tabular-nums; color: var(--heat); margin-bottom: 6px; }\n .topup-modal .topup-note { text-align: center; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; margin-bottom: 4px; }\n</style>\n</head>\n<body>\n<div id=\"page\">\n\n<div class=\"page-head\">\n <div>\n <h1>消费</h1>\n <div class=\"sub\"><span class=\"mono\">// 余额 · 充值 · 4 维消费视图 + 账单流水</span></div>\n </div>\n</div>\n\n<!-- 顶部:余额 banner(左)+ 快速充值(右)左右布局 -->\n<div class=\"top-grid\">\n <!-- 左:余额 banner -->\n <div class=\"balance-banner\">\n <span class=\"corner-tr\" aria-hidden></span><span class=\"corner-bl\" aria-hidden></span>\n <div class=\"balance-hero\">\n <div class=\"lbl\">团队余额</div>\n <div class=\"v\">¥327.40</div>\n <div class=\"meta\">// 充值累加 · 不重置</div>\n </div>\n <div class=\"balance-sub\">\n <div class=\"col\">\n <div class=\"lbl\">本月限额</div>\n <div class=\"v\">¥3,000.00</div>\n <div class=\"meta\">// 按自然月重置</div>\n </div>\n <div class=\"col\">\n <div class=\"lbl\">当月已用</div>\n <div class=\"v\">¥162.60</div>\n <div class=\"meta\">// 占比 5.4% · 健康</div>\n </div>\n </div>\n <div class=\"balance-foot\">\n <div class=\"balance-meter\" aria-label=\"本月额度使用率 5.4%\"><span></span></div>\n <div class=\"balance-foot-meta\">\n <span>团队月剩余 ¥2,837.40</span>\n <span>使用率 5.4%</span>\n </div>\n </div>\n </div>\n\n <!-- 右:快速充值 -->\n <div class=\"pane topup-pane\">\n <div class=\"topup-head\">\n <div>\n <h3>快速充值</h3>\n <div class=\"desc\">// 充值后立刻到账,可开发票 · 仅超管可操作</div>\n </div>\n <div class=\"topup-selected\" id=\"topup-selected-label\">已选 ¥500</div>\n </div>\n <div class=\"recharge-row\">\n <div class=\"recharge-card\" data-amt=\"100\" role=\"button\" tabindex=\"0\" aria-pressed=\"false\"><div class=\"amt\">¥100</div><div class=\"gift\">无赠送</div></div>\n <div class=\"recharge-card selected\" data-amt=\"500\" role=\"button\" tabindex=\"0\" aria-pressed=\"true\"><span class=\"ribbon\">推荐</span><div class=\"amt\">¥500</div><div class=\"gift bonus\">+ ¥30 赠送</div></div>\n <div class=\"recharge-card\" data-amt=\"1000\" role=\"button\" tabindex=\"0\" aria-pressed=\"false\"><div class=\"amt\">¥1000</div><div class=\"gift bonus\">+ ¥80 赠送</div></div>\n <div class=\"recharge-card\" data-amt=\"3000\" role=\"button\" tabindex=\"0\" aria-pressed=\"false\"><div class=\"amt\">¥3000</div><div class=\"gift bonus\">+ ¥300 赠送</div></div>\n </div>\n <div class=\"pay-row\">\n <div class=\"pay-title\">自定义金额</div>\n <input class=\"input\" id=\"custom-amt\" placeholder=\"最低 ¥50,可输入任意金额\">\n <div class=\"pay-btn-row\">\n <button class=\"btn pay-method-btn pay-wechat\" onclick=\"openTopup('wechat')\" aria-label=\"微信支付\">\n <span class=\"pay-logo\" aria-hidden=\"true\"><img src=\"/exact/assets/pay-wechat.png\" alt=\"\"></span>\n 微信支付\n </button>\n <button class=\"btn pay-method-btn pay-alipay\" onclick=\"openTopup('alipay')\" aria-label=\"支付宝\">\n <span class=\"pay-logo\" aria-hidden=\"true\"><img src=\"/exact/assets/pay-alipay.png\" alt=\"\"></span>\n 支付宝\n </button>\n </div>\n </div>\n </div>\n</div>\n\n<!-- Tab strip -->\n<div class=\"billing-tabs\" role=\"tablist\">\n <button class=\"tab active\" type=\"button\" data-tab=\"overview\" role=\"tab\" aria-selected=\"true\" aria-controls=\"panel-overview\">总览</button>\n <button class=\"tab\" type=\"button\" data-tab=\"by-project\" role=\"tab\" aria-selected=\"false\" aria-controls=\"panel-by-project\">项目 <span class=\"count\">8</span></button>\n <button class=\"tab\" type=\"button\" data-tab=\"by-member\" role=\"tab\" aria-selected=\"false\" aria-controls=\"panel-by-member\">成员 <span class=\"count\">5</span></button>\n <button class=\"tab\" type=\"button\" data-tab=\"bills\" role=\"tab\" aria-selected=\"false\" aria-controls=\"panel-bills\">账单流水 <span class=\"count\">30天</span></button>\n</div>\n\n<!-- ===== Tab 1: 总览 ===== -->\n<div class=\"tab-panel active\" id=\"panel-overview\">\n <div class=\"overview-grid\">\n <div class=\"pane trend-pane\">\n <div class=\"trend-head\">\n <h3>消费趋势</h3>\n <span class=\"sub\" id=\"trend-sub\">// 近 14 天 · 单位 ¥</span>\n <span class=\"spacer\"></span>\n <button class=\"chip active\" data-grain=\"day\">日</button>\n <button class=\"chip\" data-grain=\"week\">周</button>\n <button class=\"chip\" data-grain=\"month\">月</button>\n </div>\n <div class=\"trend-chart\">\n <div class=\"bars\" id=\"trend-bars\">\n <!-- JS 注入 14 根柱 -->\n </div>\n <div class=\"x-axis\" id=\"trend-xaxis\">\n <!-- JS 注入日期 -->\n </div>\n </div>\n <div class=\"trend-foot\">\n <div class=\"item\"><span class=\"k\">14 天合计</span><span class=\"v\" id=\"trend-sum\">¥0.00</span></div>\n <div class=\"item\"><span class=\"k\">日均</span><span class=\"v\" id=\"trend-avg\">¥0.00</span></div>\n <div class=\"item\"><span class=\"k\">峰值</span><span class=\"v warn\" id=\"trend-peak\">¥0.00</span></div>\n </div>\n </div>\n\n <div class=\"pane stage-pane\">\n <h3>本月按阶段分布</h3>\n <div class=\"desc\">// PRD §5.3.5 扣费规则 · 仅确认后扣</div>\n <div class=\"usage-line\"><span class=\"k\">视频片段(Seedance)</span><span class=\"v\">¥98.40</span></div>\n <div class=\"usage-bar\"><span style=\"width:60%; background:var(--heat);\"></span></div>\n <div class=\"usage-line\"><span class=\"k\">故事板(image-2)</span><span class=\"v\">¥36.00</span></div>\n <div class=\"usage-bar\"><span style=\"width:22%; background:var(--accent-forest);\"></span></div>\n <div class=\"usage-line\"><span class=\"k\">基础资产</span><span class=\"v\">¥21.00</span></div>\n <div class=\"usage-bar\"><span style=\"width:13%; background:var(--black-alpha-56);\"></span></div>\n <div class=\"usage-line\"><span class=\"k\">脚本 LLM</span><span class=\"v\">¥7.20</span></div>\n <div class=\"usage-bar\"><span style=\"width:5%; background:var(--black-alpha-32);\"></span></div>\n <div class=\"total\"><span>合计</span><span class=\"v\">¥162.60</span></div>\n </div>\n </div>\n\n <div class=\"pane rule-pane\" style=\"margin-top: 16px;\">\n <h3>扣费 + 四层额度预检规则</h3>\n <div class=\"desc\">// PRD §5.3.5 + §10.3 · 对接团队请以此页为准</div>\n <div class=\"rule-list\">\n <strong>① 失败不扣</strong>:模型超时 / 内容审核拦截 / 生成异常一律不扣费。<br>\n <strong>② 用户重跑不扣首次</strong>:第一次重跑保留原扣费,第二次起按次结算。<br>\n <strong>③ 仅在你点击 <span class=\"mono-acc\">[ 确认通过 ]</span> 时入账</strong>。<br>\n <strong>④ 导出不再扣费</strong>,所有 token 已在过程中结算。\n </div>\n <div class=\"quota-rules\">\n <div class=\"qr-head\">// 任务确认前 · 四层额度预检(任一不通过即拦截)</div>\n <div class=\"step\"><span class=\"num\">1</span><span><strong>个人日剩余</strong> ≥ 任务预估 × <span class=\"formula\">1.2</span></span></div>\n <div class=\"step\"><span class=\"num\">2</span><span><strong>个人月剩余</strong> ≥ 同上</span></div>\n <div class=\"step\"><span class=\"num\">3</span><span><strong>团队月剩余</strong> ≥ 同上</span></div>\n <div class=\"step\"><span class=\"num\">4</span><span><strong>团队总余额</strong> ≥ 同上</span></div>\n </div>\n </div>\n</div>\n\n<!-- ===== Tab 2: 按项目 ===== -->\n<div class=\"tab-panel\" id=\"panel-by-project\">\n <div class=\"filter-bar\">\n <select id=\"proj-f-status\">\n <option value=\"all\">全部状态</option>\n <option value=\"wip\">进行中</option>\n <option value=\"ok\">已完成</option>\n <option value=\"fail\">失败 · 待重跑</option>\n </select>\n <select id=\"proj-f-range\">\n <option value=\"month\">本月</option>\n <option value=\"30d\">近 30 天</option>\n <option value=\"90d\">近 90 天</option>\n </select>\n <button class=\"chip\" id=\"proj-f-reset\" type=\"button\" style=\"height:30px;font-size:11px;display:none;\">清除筛选</button>\n <span class=\"spacer\"></span>\n <span class=\"ct\" id=\"proj-count\">共 <b style=\"color:var(--accent-black);\">0</b> 个项目 · 消耗 ¥0.00</span>\n </div>\n <table class=\"billing-table\">\n <thead>\n <tr>\n <th>项目</th>\n <th>商品</th>\n <th>所属成员</th>\n <th>当前阶段</th>\n <th>状态</th>\n <th style=\"text-align:right;\">消耗</th>\n </tr>\n </thead>\n <tbody id=\"proj-body\">\n <!-- JS 注入 -->\n </tbody>\n </table>\n <div id=\"proj-empty\" class=\"empty-state\" style=\"display:none; padding:48px 0; text-align:center; font-family:var(--font-mono); font-size:12px; color:var(--black-alpha-48); letter-spacing:.02em;\">\n // 当前筛选条件下没有项目 · 试试调整状态 / 时间范围\n </div>\n</div>\n\n<!-- ===== Tab 3: 按成员 ===== -->\n<div class=\"tab-panel\" id=\"panel-by-member\">\n <div class=\"filter-bar\">\n <select id=\"mem-f-role\">\n <option value=\"all\">全部角色</option>\n <option value=\"super\">超管</option>\n <option value=\"admin\">团管</option>\n <option value=\"member\">成员</option>\n </select>\n <select id=\"mem-f-range\">\n <option value=\"month\">本月</option>\n <option value=\"30d\">近 30 天</option>\n <option value=\"90d\">近 90 天</option>\n </select>\n <button class=\"chip\" id=\"mem-f-reset\" type=\"button\" style=\"height:30px;font-size:11px;display:none;\">清除筛选</button>\n <span class=\"spacer\"></span>\n <span class=\"ct\" id=\"mem-count\">共 <b style=\"color:var(--accent-black);\">0</b> 人 · 合计 ¥0.00</span>\n </div>\n <table class=\"billing-table\">\n <thead>\n <tr>\n <th>成员</th>\n <th>角色</th>\n <th>已完成项目</th>\n <th>已用 / 月度额度</th>\n <th>最近活跃</th>\n </tr>\n </thead>\n <tbody id=\"member-body\">\n <!-- JS 注入 -->\n </tbody>\n </table>\n <div id=\"mem-empty\" class=\"empty-state\" style=\"display:none; padding:48px 0; text-align:center; font-family:var(--font-mono); font-size:12px; color:var(--black-alpha-48); letter-spacing:.02em;\">\n // 当前筛选条件下没有成员 · 试试调整角色 / 时间范围\n </div>\n</div>\n\n<!-- ===== Tab 4: 账单流水 ===== -->\n<div class=\"tab-panel\" id=\"panel-bills\">\n <div class=\"filter-bar\">\n <select id=\"bills-f-stage\">\n <option value=\"all\">全部阶段</option>\n <option value=\"视频片段\">视频片段</option>\n <option value=\"故事板\">故事板</option>\n <option value=\"基础资产\">基础资产</option>\n <option value=\"脚本 LLM\">脚本 LLM</option>\n <option value=\"充值\">充值</option>\n <option value=\"导出\">导出</option>\n </select>\n <select id=\"bills-f-member\">\n <option value=\"all\">全部成员</option>\n <option value=\"李\">小李</option>\n <option value=\"张\">张运营</option>\n <option value=\"王\">王小姐</option>\n <option value=\"陈\">陈策划</option>\n </select>\n <select id=\"bills-f-range\">\n <option value=\"30d\">近 30 天</option>\n <option value=\"7d\">近 7 天</option>\n <option value=\"month\">本月</option>\n <option value=\"all\">全部</option>\n </select>\n <button class=\"chip\" id=\"bills-f-reset\" type=\"button\" style=\"height:30px;font-size:11px;display:none;\">清除筛选</button>\n <span class=\"spacer\"></span>\n <span class=\"ct\">共 <b id=\"bills-count\" style=\"color:var(--accent-black);\">0</b> 条</span>\n </div>\n <table class=\"billing-table\">\n <thead>\n <tr>\n <th>时间</th>\n <th>项目 / 类型</th>\n <th>详情</th>\n <th>成员</th>\n <th>状态</th>\n <th style=\"text-align:right;\">金额</th>\n </tr>\n </thead>\n <tbody id=\"bills-body\">\n <!-- JS 注入 -->\n </tbody>\n </table>\n <div id=\"bills-empty\" class=\"empty-state\" style=\"display:none; padding:48px 0; text-align:center; font-family:var(--font-mono); font-size:12px; color:var(--black-alpha-48); letter-spacing:.02em;\">\n // 当前筛选条件下没有账单 · 试试调整阶段 / 成员 / 时间范围\n </div>\n</div>\n\n<!-- 充值 modal -->\n<div class=\"modal-bg\" id=\"topup-bg\" onclick=\"if(event.target===this)Shell.closeModal('topup-bg')\">\n <div class=\"modal topup-modal\">\n <span class=\"corner-tr\" aria-hidden></span><span class=\"corner-bl\" aria-hidden></span>\n <div class=\"modal-h\">\n <div class=\"ic-m\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 7v10M9 10h4a1.5 1.5 0 0 1 0 3h-3a1.5 1.5 0 0 0 0 3h4\"/></svg>\n </div>\n <div class=\"ti\">扫码支付<span id=\"topup-channel-label\">// 微信支付</span></div>\n </div>\n <div class=\"modal-b\">\n <div class=\"topup-info\">支付金额</div>\n <div class=\"topup-amt\" id=\"topup-amt\">¥500.00</div>\n <div class=\"topup-note\" id=\"topup-bonus\">// 含 ¥30 赠送 · 实到账 ¥530</div>\n <div class=\"topup-qr\">\n <div class=\"center\" id=\"topup-channel-name\">微信扫码<br><span style=\"color:var(--black-alpha-32);\">/topup/wx/TX...</span></div>\n </div>\n <div style=\"text-align:center; font-family:var(--font-mono); font-size:11px; color:var(--black-alpha-48); letter-spacing:.02em;\">// 5 分钟内有效 · 到账后自动关闭</div>\n </div>\n <div class=\"modal-f\">\n <button class=\"btn\" type=\"button\" onclick=\"Shell.closeModal('topup-bg')\">取消</button>\n <button class=\"btn btn-primary\" type=\"button\" onclick=\"topupDone()\">已完成支付</button>\n </div>\n </div>\n</div>\n\n</div>\n<script src=\"/exact/assets/icons.js?v=2026052608\"></script>\n<script src=\"/exact/assets/shell.js?v=2026060101\"></script>\n<script>\nShell.render({ active: 'account', crumbs: [{ label: '工作台', href: 'index.html' }, { label: '消费' }] });\n\n/* ============================================================\n Mock 数据\n ============================================================ */\nconst TREND_DAYS = [\n { d: '05.08', v: 12.40 }, { d: '05.09', v: 18.80 }, { d: '05.10', v: 6.20 }, { d: '05.11', v: 0 },\n { d: '05.12', v: 4.50 }, { d: '05.13', v: 22.10 }, { d: '05.14', v: 14.60 }, { d: '05.15', v: 9.30 },\n { d: '05.16', v: 28.40 }, { d: '05.17', v: 13.80 }, { d: '05.18', v: 8.20 }, { d: '05.19', v: 11.50 },\n { d: '05.20', v: 19.40 }, { d: '05.21', v: 7.80 },\n];\n\n// 8 周(以「W18~W21 + 前 4 周」做演示),按 7 天合计估算\nconst TREND_WEEKS = [\n { d: 'W14', v: 64.20 }, { d: 'W15', v: 92.80 }, { d: 'W16', v: 118.40 }, { d: 'W17', v: 78.60 },\n { d: 'W18', v: 102.30 }, { d: 'W19', v: 138.20 }, { d: 'W20', v: 86.40 }, { d: 'W21', v: 27.20 },\n];\n// 6 个月 demo,自然月聚合估算 + 当月真实合计 162.60\nconst TREND_MONTHS = [\n { d: '2025-12', v: 384.20 }, { d: '2026-01', v: 528.40 }, { d: '2026-02', v: 296.80 },\n { d: '2026-03', v: 412.00 }, { d: '2026-04', v: 348.60 }, { d: '2026-05', v: 162.60 },\n];\nconst TREND_DATA = { day: TREND_DAYS, week: TREND_WEEKS, month: TREND_MONTHS };\nconst TREND_LABEL = {\n day: { sub: '// 近 14 天 · 单位 ¥', sumLbl: '14 天合计', avgLbl: '日均' },\n week: { sub: '// 近 8 周 · 单位 ¥', sumLbl: '8 周合计', avgLbl: '周均' },\n month: { sub: '// 近 6 月 · 单位 ¥', sumLbl: '6 月合计', avgLbl: '月均' },\n};\nlet _trendGrain = 'day';\n\nconst PROJECTS_BILL = [\n { name: '补水面膜 · v3', product: '透真补水面膜', owner: '李', role: 'super', stage: 'Stage 3 故事板', stagePct: 60, status: 'wip', statusLabel: '进行中', amount: 48.20 },\n { name: '透真防晒 · 通勤对比', product: '透真防晒', owner: '李', role: 'super', stage: 'Stage 5 导出', stagePct: 100, status: 'ok', statusLabel: '已完成', amount: 32.60 },\n { name: '蓝牙耳机 · 开箱测评', product: 'Pro 4 蓝牙耳机', owner: '张', role: 'admin', stage: 'Stage 4 视频', stagePct: 80, status: 'wip', statusLabel: '进行中', amount: 28.40 },\n { name: '速食面 · 加班场景', product: '熊猫速食面', owner: '陈', role: 'member', stage: 'Stage 5 导出', stagePct: 100, status: 'ok', statusLabel: '已完成', amount: 12.80 },\n { name: '春日新品 · 立体口红', product: '凝彩立体口红', owner: '李', role: 'super', stage: 'Stage 2 资产', stagePct: 40, status: 'wip', statusLabel: '进行中', amount: 18.30 },\n { name: '咖啡冻干 · 早八', product: '冷萃咖啡冻干', owner: '王', role: 'member', stage: 'Stage 3 故事板', stagePct: 60, status: 'fail', statusLabel: '失败 · 待重跑', amount: 0 },\n { name: '瑜伽裤 · 通勤穿搭', product: '透气速干瑜伽裤', owner: '王', role: 'member', stage: 'Stage 5 导出', stagePct: 100, status: 'ok', statusLabel: '已完成', amount: 14.80 },\n { name: '保温杯 · 户外随行', product: '316 保温杯', owner: '张', role: 'admin', stage: 'Stage 1 脚本', stagePct: 20, status: 'wip', statusLabel: '进行中', amount: 7.50 },\n];\n\nconst MEMBERS_BILL = [\n { av: '李', name: '小李', role: 'super', projectsDone: 14, used: 162.60, monthly: 10000, lastActive: '15 分钟前' },\n { av: '张', name: '张运营', role: 'admin', projectsDone: 8, used: 98.40, monthly: 6000, lastActive: '10 分钟前' },\n { av: '王', name: '王小姐', role: 'member', projectsDone: 4, used: 45.20, monthly: 2000, lastActive: '28 分钟前' },\n { av: '陈', name: '陈策划', role: 'member', projectsDone: 1, used: 12.80, monthly: 2000, lastActive: '4 小时前' },\n { av: '林', name: '林新人', role: 'member', projectsDone: 0, used: 0.00, monthly: 2000, lastActive: '尚未激活' },\n];\n\nconst BILLS = [\n { ts: '05.21 14:32', proj: '补水面膜 · v3', type: '视频片段', detail: 'Seedance · 场 1 · 1 镜', who: '李', role: 'super', status: 'ok', statusLabel: '通过', amount: -0.45 },\n { ts: '05.21 14:08', proj: '补水面膜 · v3', type: '视频片段', detail: 'Seedance · 场 2 · 1 镜', who: '张', role: 'admin', status: 'ok', statusLabel: '通过', amount: -0.45 },\n { ts: '05.20 21:42', proj: '蓝牙耳机 · 开箱测评', type: '视频片段', detail: 'Seedance · 场 3 · 6 镜', who: '张', role: 'admin', status: 'ok', statusLabel: '通过', amount: -1.20 },\n { ts: '05.20 18:21', proj: '透真防晒 · 通勤对比', type: '视频片段', detail: 'Seedance · 整段 · 6 镜', who: '李', role: 'super', status: 'ok', statusLabel: '通过', amount: -1.20 },\n { ts: '05.20 16:00', proj: '蓝牙耳机 · 开箱测评', type: '故事板', detail: 'image-2 · 整张重跑 · 场 2', who: '张', role: 'admin', status: 'ok', statusLabel: '通过', amount: -0.45 },\n { ts: '05.20 11:02', proj: '充值', type: '充值', detail: '微信支付 · TX2024052011021Z', who: '李', role: 'super', status: 'ok', statusLabel: '到账', amount: 500.00 },\n { ts: '05.19 18:08', proj: '速食面 · 加班场景', type: '故事板', detail: 'image-2 · 场 1', who: '陈', role: 'member', status: 'ok', statusLabel: '通过', amount: -0.30 },\n { ts: '05.19 16:08', proj: '补水面膜 · v3', type: '故事板', detail: 'image-2 · 场 1', who: '李', role: 'super', status: 'ok', statusLabel: '通过', amount: -0.45 },\n { ts: '05.19 14:02', proj: '补水面膜 · v3', type: '脚本 LLM', detail: '2.4k tokens · AI 全生', who: '李', role: 'super', status: 'ok', statusLabel: '通过', amount: -0.04 },\n { ts: '05.19 13:38', proj: '补水面膜 · v3', type: '基础资产', detail: 'image-2 · 5 张', who: '李', role: 'super', status: 'ok', statusLabel: '通过', amount: -1.05 },\n { ts: '05.19 11:18', proj: '补水面膜 · v3', type: '故事板', detail: 'image-2 · 场 2', who: '张', role: 'admin', status: 'ok', statusLabel: '通过', amount: -0.45 },\n { ts: '05.19 11:12', proj: '速食面 · 加班场景', type: '基础资产', detail: 'image-2 · 2 张', who: '陈', role: 'member', status: 'ok', statusLabel: '通过', amount: -0.42 },\n { ts: '05.18 15:42', proj: '咖啡冻干 · 早八', type: '故事板', detail: 'image-2 · 场 3', who: '王', role: 'member', status: 'fail', statusLabel: '失败不扣', amount: 0 },\n { ts: '05.18 09:42', proj: '蓝牙耳机 · 开箱测评', type: '基础资产', detail: 'image-2 · 4 张', who: '张', role: 'admin', status: 'ok', statusLabel: '通过', amount: -0.84 },\n { ts: '05.17 14:38', proj: '蓝牙耳机 · 开箱测评', type: '脚本 LLM', detail: '1.8k tokens · 自带粘贴', who: '张', role: 'admin', status: 'ok', statusLabel: '通过', amount: -0.03 },\n { ts: '05.17 10:30', proj: '瑜伽裤 · 通勤穿搭', type: '导出', detail: '1080×1920 · 9:16 · 38s', who: '王', role: 'member', status: 'ok', statusLabel: '免费', amount: 0 },\n { ts: '05.17 10:08', proj: '瑜伽裤 · 通勤穿搭', type: '视频片段', detail: 'Seedance · 整段 · 5 镜', who: '王', role: 'member', status: 'ok', statusLabel: '通过', amount: -3.20 },\n { ts: '05.16 19:38', proj: '透真防晒 · 通勤对比', type: '视频片段', detail: 'Seedance · 4 镜', who: '王', role: 'member', status: 'ok', statusLabel: '通过', amount: -0.80 },\n { ts: '05.16 11:42', proj: '透真防晒 · 通勤对比', type: '故事板', detail: 'image-2 · 场 2', who: '王', role: 'member', status: 'ok', statusLabel: '通过', amount: -0.45 },\n { ts: '05.15 16:08', proj: '透真防晒 · 通勤对比', type: '基础资产', detail: 'image-2 · 3 张', who: '王', role: 'member', status: 'ok', statusLabel: '通过', amount: -0.63 },\n];\n\nconst ROLE_META = {\n super: { label: '超管', cls: 'role-super' },\n admin: { label: '团管', cls: 'role-admin' },\n member: { label: '成员', cls: 'role-member' },\n};\n\nfunction fmtMoney(n) { return '¥' + Math.abs(n).toFixed(2).replace(/\\B(?=(\\d{3})+(?!\\d))/g, ','); }\nfunction amtStr(n) {\n if (n === 0) return '¥0.00';\n return (n > 0 ? '+' : '-') + fmtMoney(n);\n}\nfunction amtCls(n) { return n > 0 ? 'pos' : (n === 0 ? 'zero' : 'neg'); }\n\n/* ─── 趋势柱 ─── */\nfunction renderTrend() {\n const data = TREND_DATA[_trendGrain];\n const meta = TREND_LABEL[_trendGrain];\n const bars = document.getElementById('trend-bars');\n const xax = document.getElementById('trend-xaxis');\n const max = Math.max(...data.map(d => d.v));\n // bars 的 grid-template-columns 默认是 repeat(14, 1fr),需要按数据长度动态调整\n bars.style.gridTemplateColumns = `repeat(${data.length}, 1fr)`;\n xax.style.gridTemplateColumns = `repeat(${data.length}, 1fr)`;\n bars.innerHTML = data.map(d => {\n const h = max > 0 ? (d.v / max * 100) : 0;\n const isPeak = d.v === max;\n return `<div class=\"bar${isPeak ? ' peak' : ''}\" title=\"${d.d} · ${fmtMoney(d.v)}\"><span style=\"height: ${h.toFixed(1)}%\"></span></div>`;\n }).join('');\n // x 轴标签:日 = 隔列显示 MM·DD 的 DD 部分;周/月 = 全显示\n if (_trendGrain === 'day') {\n xax.innerHTML = data.map((d, i) => i % 2 === 0 ? `<span>${d.d.slice(3)}</span>` : '<span></span>').join('');\n } else if (_trendGrain === 'week') {\n xax.innerHTML = data.map(d => `<span>${d.d}</span>`).join('');\n } else {\n xax.innerHTML = data.map(d => `<span>${d.d.slice(5)}</span>`).join('');\n }\n const sum = data.reduce((s, d) => s + d.v, 0);\n document.getElementById('trend-sub').textContent = meta.sub;\n document.getElementById('trend-sum').textContent = fmtMoney(sum);\n document.getElementById('trend-avg').textContent = fmtMoney(sum / data.length);\n document.getElementById('trend-peak').textContent = fmtMoney(max);\n // 旁标 label\n document.querySelectorAll('.trend-foot .item').forEach((it, idx) => {\n const k = it.querySelector('.k');\n if (idx === 0 && k) k.textContent = meta.sumLbl;\n else if (idx === 1 && k) k.textContent = meta.avgLbl;\n });\n}\n\n/* ─── 趋势 日/周/月 切换 ─── */\ndocument.querySelectorAll('.trend-head .chip[data-grain]').forEach(chip => {\n chip.addEventListener('click', () => {\n document.querySelectorAll('.trend-head .chip[data-grain]').forEach(c => c.classList.remove('active'));\n chip.classList.add('active');\n _trendGrain = chip.dataset.grain;\n renderTrend();\n });\n});\n\n/* ─── 按项目 表格 + 筛选 ─── */\nconst PROJ_FILTER = { status: 'all', range: 'month' };\nconst RANGE_MULT = { 'month': 1, '30d': 1, '90d': 2.6 }; // 演示用,30/90 天放大倍率(月 = 当月真实)\nconst RANGE_LBL = { 'month': '当月', '30d': '近 30 天', '90d': '近 90 天' };\nfunction renderProjects() {\n const tb = document.getElementById('proj-body');\n const mult = RANGE_MULT[PROJ_FILTER.range] || 1;\n const list = PROJECTS_BILL.filter(p => PROJ_FILTER.status === 'all' || p.status === PROJ_FILTER.status);\n tb.innerHTML = list.map(p => {\n const r = ROLE_META[p.role];\n const amt = p.amount * mult;\n return `\n <tr>\n <td><a href=\"pipeline.html?product=${encodeURIComponent(p.product)}\" style=\"color:var(--accent-black);text-decoration:none;font-weight:500;\">${p.name}</a></td>\n <td class=\"muted\">${p.product}</td>\n <td><span class=\"who\"><span class=\"av\">${p.owner}</span><span class=\"role-pill ${r.cls}\"><span class=\"dot\"></span>${r.label}</span></span></td>\n <td><span class=\"muted\">${p.stage}</span><span class=\"progress-mini\"><span style=\"width:${p.stagePct}%\"></span></span></td>\n <td><span class=\"status-tag ${p.status}\">${p.statusLabel}</span></td>\n <td class=\"${amtCls(-amt)}\">${amt === 0 ? '¥0.00' : '-' + fmtMoney(amt)}</td>\n </tr>\n `;\n }).join('');\n const sum = list.reduce((s, p) => s + p.amount * mult, 0);\n document.getElementById('proj-count').innerHTML =\n `共 <b style=\"color:var(--accent-black);\">${list.length}</b> 个项目 · ${RANGE_LBL[PROJ_FILTER.range]}消耗 ${fmtMoney(sum)}`;\n const empty = document.getElementById('proj-empty');\n const tbl = document.querySelector('#panel-by-project .billing-table');\n empty.style.display = list.length === 0 ? '' : 'none';\n tbl.style.display = list.length === 0 ? 'none' : '';\n const isDef = PROJ_FILTER.status === 'all' && PROJ_FILTER.range === 'month';\n document.getElementById('proj-f-reset').style.display = isDef ? 'none' : '';\n}\n\n['status', 'range'].forEach(key => {\n const sel = document.getElementById('proj-f-' + key);\n sel.addEventListener('change', () => { PROJ_FILTER[key] = sel.value; renderProjects(); });\n});\ndocument.getElementById('proj-f-reset').addEventListener('click', () => {\n PROJ_FILTER.status = 'all'; PROJ_FILTER.range = 'month';\n document.getElementById('proj-f-status').value = 'all';\n document.getElementById('proj-f-range').value = 'month';\n renderProjects();\n});\n\n/* ─── 按成员 表格 + 筛选 ─── */\nconst MEM_FILTER = { role: 'all', range: 'month' };\nfunction renderMembers() {\n const tb = document.getElementById('member-body');\n const mult = RANGE_MULT[MEM_FILTER.range] || 1;\n const list = MEMBERS_BILL.filter(m => MEM_FILTER.role === 'all' || m.role === MEM_FILTER.role);\n tb.innerHTML = list.map(m => {\n const r = ROLE_META[m.role];\n const used = m.used * mult;\n const pct = m.monthly > 0 ? (used / m.monthly * 100) : 0;\n return `\n <tr style=\"cursor:pointer;\">\n <td><span class=\"who\"><span class=\"av\">${m.av}</span><strong style=\"font-weight:500;\">${m.name}</strong></span></td>\n <td><span class=\"role-pill ${r.cls}\"><span class=\"dot\"></span>${r.label}</span></td>\n <td>${m.projectsDone}</td>\n <td>\n <strong style=\"font-variant-numeric:tabular-nums;font-weight:600;\">${fmtMoney(used)}</strong>\n <span class=\"muted\"> / ${fmtMoney(m.monthly)} · ${pct.toFixed(1)}%</span>\n <span class=\"progress-mini\"><span style=\"width:${Math.min(100, pct)}%; background:${pct >= 85 ? '#B45309' : 'var(--heat)'};\"></span></span>\n </td>\n <td><span class=\"ts\">${m.lastActive}</span></td>\n </tr>\n `;\n }).join('');\n const sum = list.reduce((s, m) => s + m.used * mult, 0);\n document.getElementById('mem-count').innerHTML =\n `共 <b style=\"color:var(--accent-black);\">${list.length}</b> 人 · ${RANGE_LBL[MEM_FILTER.range]}合计 ${fmtMoney(sum)}`;\n const empty = document.getElementById('mem-empty');\n const tbl = document.querySelector('#panel-by-member .billing-table');\n empty.style.display = list.length === 0 ? '' : 'none';\n tbl.style.display = list.length === 0 ? 'none' : '';\n const isDef = MEM_FILTER.role === 'all' && MEM_FILTER.range === 'month';\n document.getElementById('mem-f-reset').style.display = isDef ? 'none' : '';\n}\n\n['role', 'range'].forEach(key => {\n const sel = document.getElementById('mem-f-' + key);\n sel.addEventListener('change', () => { MEM_FILTER[key] = sel.value; renderMembers(); });\n});\ndocument.getElementById('mem-f-reset').addEventListener('click', () => {\n MEM_FILTER.role = 'all'; MEM_FILTER.range = 'month';\n document.getElementById('mem-f-role').value = 'all';\n document.getElementById('mem-f-range').value = 'month';\n renderMembers();\n});\n\n/* ─── 账单流水:筛选 + 表格渲染 ─── */\nconst BILLS_FILTER = { stage: 'all', member: 'all', range: '30d' };\n// demo \"今天\" = 05.21,所有 ts 都是 MM.DD 格式,这里基于这一假定算时间区间\nconst TODAY_MD = '05.21';\nfunction mdToDay(md) {\n // 把 \"MM.DD\" 当成 2026 年的日子换算成 1970 epoch ms,只用来比较先后\n const [m, d] = md.split('.').map(Number);\n return Date.UTC(2026, (m || 1) - 1, d || 1);\n}\nconst TODAY_MS = mdToDay(TODAY_MD);\n\nfunction passRange(ts, range) {\n if (range === 'all') return true;\n const md = ts.slice(0, 5); // \"05.21\"\n if (range === 'month') return md.startsWith(TODAY_MD.slice(0, 3));\n const diff = TODAY_MS - mdToDay(md);\n if (range === '7d') return diff <= 6 * 86400000;\n if (range === '30d') return diff <= 29 * 86400000;\n return true;\n}\n\nfunction getFilteredBills() {\n return BILLS.filter(b => {\n if (BILLS_FILTER.stage !== 'all' && b.type !== BILLS_FILTER.stage) return false;\n if (BILLS_FILTER.member !== 'all' && b.who !== BILLS_FILTER.member) return false;\n if (!passRange(b.ts, BILLS_FILTER.range)) return false;\n return true;\n });\n}\n\nfunction renderBills() {\n const tb = document.getElementById('bills-body');\n const list = getFilteredBills();\n tb.innerHTML = list.map(b => {\n const r = ROLE_META[b.role];\n return `\n <tr>\n <td class=\"ts\">${b.ts}</td>\n <td><strong style=\"font-weight:500;\">${b.proj}</strong><br><span class=\"muted\">${b.type}</span></td>\n <td class=\"muted\">${b.detail}</td>\n <td><span class=\"who\"><span class=\"av\">${b.who}</span></span></td>\n <td><span class=\"status-tag ${b.status}\">${b.statusLabel}</span></td>\n <td class=\"${amtCls(b.amount)}\">${b.amount === 0 ? '¥0.00' : (b.amount > 0 ? '+' + fmtMoney(b.amount) : '-' + fmtMoney(b.amount))}</td>\n </tr>\n `;\n }).join('');\n document.getElementById('bills-count').textContent = list.length;\n const empty = document.getElementById('bills-empty');\n const table = document.querySelector('#panel-bills .billing-table');\n if (list.length === 0) {\n empty.style.display = '';\n table.style.display = 'none';\n } else {\n empty.style.display = 'none';\n table.style.display = '';\n }\n // 「清除筛选」按钮:任何一个非默认就显示\n const isDefault = BILLS_FILTER.stage === 'all' && BILLS_FILTER.member === 'all' && BILLS_FILTER.range === '30d';\n document.getElementById('bills-f-reset').style.display = isDefault ? 'none' : '';\n}\n\n/* ─── 筛选绑定 ─── */\n['stage', 'member', 'range'].forEach(key => {\n const sel = document.getElementById('bills-f-' + key);\n sel.addEventListener('change', () => {\n BILLS_FILTER[key] = sel.value;\n renderBills();\n });\n});\ndocument.getElementById('bills-f-reset').addEventListener('click', () => {\n BILLS_FILTER.stage = 'all'; BILLS_FILTER.member = 'all'; BILLS_FILTER.range = '30d';\n document.getElementById('bills-f-stage').value = 'all';\n document.getElementById('bills-f-member').value = 'all';\n document.getElementById('bills-f-range').value = '30d';\n renderBills();\n});\n\n/* ─── Tab 切换 ─── */\ndocument.querySelectorAll('.billing-tabs .tab').forEach(tab => {\n tab.addEventListener('click', () => {\n const targetId = 'panel-' + tab.dataset.tab;\n document.querySelectorAll('.billing-tabs .tab').forEach(t => {\n const active = t === tab;\n t.classList.toggle('active', active);\n t.setAttribute('aria-selected', active ? 'true' : 'false');\n });\n document.querySelectorAll('.tab-panel').forEach(p => {\n p.classList.toggle('active', p.id === targetId);\n });\n });\n});\n\n/* ─── 快速充值卡选择 ─── */\nfunction setRechargeCard(card) {\n document.querySelectorAll('.recharge-card').forEach(c => {\n const active = c === card;\n c.classList.toggle('selected', active);\n c.setAttribute('aria-pressed', active ? 'true' : 'false');\n });\n const label = document.getElementById('topup-selected-label');\n if (label && card) label.textContent = '已选 ¥' + Number(card.dataset.amt).toLocaleString('zh-CN');\n}\ndocument.querySelectorAll('.recharge-card').forEach(card => {\n card.addEventListener('click', () => {\n setRechargeCard(card);\n document.getElementById('custom-amt').value = '';\n });\n card.addEventListener('keydown', e => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n setRechargeCard(card);\n document.getElementById('custom-amt').value = '';\n }\n });\n});\ndocument.getElementById('custom-amt').addEventListener('input', e => {\n const raw = e.target.value.trim();\n const label = document.getElementById('topup-selected-label');\n if (!raw) {\n const selected = document.querySelector('.recharge-card.selected');\n if (label && selected) label.textContent = '已选 ¥' + Number(selected.dataset.amt).toLocaleString('zh-CN');\n return;\n }\n document.querySelectorAll('.recharge-card').forEach(c => {\n c.classList.remove('selected');\n c.setAttribute('aria-pressed', 'false');\n });\n if (label) label.textContent = Number(raw) >= 50 ? '自定义 ¥' + Number(raw).toLocaleString('zh-CN') : '最低 ¥50';\n});\n\n/* ─── 充值 modal ─── */\nfunction openTopup(channel) {\n const selected = document.querySelector('.recharge-card.selected');\n const customRaw = document.getElementById('custom-amt').value.trim();\n const custom = Number(customRaw);\n let amt = 500, bonus = 30;\n if (custom >= 50) { amt = custom; bonus = 0; }\n else if (selected) {\n amt = Number(selected.dataset.amt);\n const bonusEl = selected.querySelector('.gift.bonus');\n bonus = bonusEl ? Number(bonusEl.textContent.replace(/\\D/g, '')) : 0;\n }\n document.getElementById('topup-amt').textContent = fmtMoney(amt);\n document.getElementById('topup-bonus').textContent = bonus > 0\n ? `// 含 ¥${bonus} 赠送 · 实到账 ¥${(amt + bonus).toFixed(2)}`\n : '// 无赠送';\n const isAlipay = channel === 'alipay';\n document.getElementById('topup-channel-label').textContent = isAlipay ? '// 支付宝' : '// 微信支付';\n document.getElementById('topup-channel-name').innerHTML = (isAlipay ? '支付宝扫码' : '微信扫码') +\n '<br><span style=\"color:var(--black-alpha-32);\">/topup/' + (isAlipay ? 'ali' : 'wx') + '/TX' + Date.now() + '</span>';\n Shell.openModal('topup-bg');\n}\nfunction topupDone() {\n Shell.closeModal('topup-bg');\n Shell.toast('充值成功', '余额已更新 · 可开发票');\n}\n\n/* ─── 初始化 ─── */\nrenderTrend();\nrenderProjects();\nrenderMembers();\nrenderBills();\n</script>\n<script src=\"/exact/assets/api-bridge.js?v=2026060114\"></script>\n</body>\n</html>\n",
|
||
"assetFactory": "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta name=\"x-airshelf-exact-source\" content=\"asset-factory.html\">\n<meta charset=\"utf-8\">\n<title>图片生成 · Airshelf</title>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"/exact/assets/restraint.css?v=2026060101\">\n<style>\n #page-content { padding: 24px 28px 60px; }\n\n /* ─── 三 Hero 卡片网格(模特上身图 / 平台套图 / 图片创作 · 等比)─── */\n .factory-hero {\n display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 16px; margin-bottom: 56px;\n }\n @media (max-width: 1400px) { .factory-hero { grid-template-columns: 1fr 1fr; } }\n @media (max-width: 1000px) { .factory-hero { grid-template-columns: 1fr; } }\n\n .factory-card {\n position: relative;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n padding: 28px 30px;\n overflow: hidden;\n }\n\n /* 卡片内 · 文上图下 单列(3 卡并排时保持视觉一致)*/\n .factory-body {\n display: flex; flex-direction: column;\n gap: 18px;\n height: 100%;\n }\n\n .factory-text { display: flex; flex-direction: column; min-width: 0; }\n .factory-tag {\n align-self: flex-start;\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48); letter-spacing: .06em;\n padding: 2px 8px;\n border: 1px solid var(--border-faint);\n background: var(--background-lighter);\n border-radius: var(--r-sm);\n margin-bottom: 14px;\n }\n .factory-title {\n font-size: 22px; font-weight: 600;\n letter-spacing: -.018em; line-height: 1.25;\n color: var(--accent-black);\n }\n .factory-desc {\n margin-top: 8px;\n font-size: 13.5px; color: var(--black-alpha-64); line-height: 1.55;\n }\n\n /* feature 列表 */\n .factory-features {\n list-style: none; padding: 0;\n margin: 22px 0 0;\n display: flex; flex-direction: column; gap: 11px;\n }\n .factory-features li {\n display: flex; align-items: center; gap: 10px;\n font-size: 13px; color: var(--black-alpha-72); font-weight: 500;\n }\n .factory-features .ff-ic {\n width: 26px; height: 26px;\n display: inline-flex; align-items: center; justify-content: center;\n background: var(--heat-12);\n color: var(--heat);\n border-radius: var(--r-md);\n flex-shrink: 0;\n }\n .factory-features .ff-ic svg { width: 14px; height: 14px; }\n\n /* 平台 chip 行 */\n .platform-row {\n display: flex; flex-wrap: wrap; gap: 8px;\n margin-top: 20px;\n }\n .platform-chip {\n display: inline-flex; align-items: center; gap: 7px;\n height: 30px; padding: 0 12px 0 8px;\n border: 1px solid var(--border-faint);\n background: var(--surface);\n border-radius: var(--r-pill);\n transition: background var(--t-base);\n cursor: pointer;\n }\n .platform-chip:hover { background: var(--black-alpha-4); }\n .platform-chip .code {\n display: inline-flex; align-items: center; justify-content: center;\n width: 20px; height: 20px;\n background: var(--accent-black); color: var(--accent-white);\n font-family: var(--font-mono); font-size: 8.5px; font-weight: 600;\n border-radius: var(--r-pill); letter-spacing: .04em;\n }\n .platform-chip .nm {\n font-size: 12px; color: var(--accent-black); font-weight: 500;\n }\n\n /* CTA 行:主按钮 + 价格 mono */\n .factory-cta {\n margin-top: auto; padding-top: 24px;\n display: flex; align-items: center; gap: 14px;\n }\n .factory-cta .cost {\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48); letter-spacing: .04em;\n }\n\n /* 视觉占位 + feature 列表已隐藏 — CTA 卡片只保留标题 + 描述 + 按钮 */\n .factory-visual { display: none; }\n .factory-features { display: none; }\n .model-visual { grid-template-columns: repeat(4, 1fr); }\n .model-visual .main { aspect-ratio: 3 / 4; grid-column: span 1; }\n .model-visual .stack { display: contents; }\n .model-visual .stack .placeholder { aspect-ratio: 3 / 4; }\n .kit-visual { grid-template-columns: repeat(4, 1fr); }\n .kit-visual .placeholder { aspect-ratio: 1 / 1; }\n\n /* ─── 任务中心 · section header ─── */\n .section-h { display: flex; align-items: center; gap: 12px; margin-top: 24px; margin-bottom: 14px; }\n .section-h h2 { font-size: 18px; font-weight: 600; letter-spacing: -.01em; }\n .section-h .sub-mono { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em; }\n\n /* ─── 视图切换 (复用 projects.html · 图标 + 文字) ─── */\n .view-toggle { display: inline-flex; border: 1px solid var(--border-faint); border-radius: var(--r-md); overflow: hidden; }\n .view-toggle button { padding: 0 14px; background: var(--surface); color: var(--black-alpha-56); font-size: 13px; border: 0; border-right: 1px solid var(--border-faint); border-radius: 0; height: 36px; cursor: pointer; font-family: inherit; display: flex; align-items: center; gap: 6px; transition: background var(--t-base), color var(--t-base); }\n .view-toggle button:last-child { border-right: 0; }\n .view-toggle button:hover { background: var(--background-lighter); color: var(--accent-black); }\n .view-toggle button.active { background: var(--heat-12); color: var(--heat); font-weight: 600; }\n .view-toggle button svg { width: 13px; height: 13px; }\n\n /* ─── 网格视图 · 卡片(原 history-grid) ─── */\n .history-grid {\n display: grid; grid-template-columns: repeat(4, 1fr);\n gap: 16px;\n }\n @media (max-width: 1280px) { .history-grid { grid-template-columns: repeat(3, 1fr); } }\n @media (max-width: 960px) { .history-grid { grid-template-columns: repeat(2, 1fr); } }\n .history-grid[hidden] { display: none; }\n\n .history-card {\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n padding: 12px;\n display: grid; grid-template-columns: 78px 1fr; gap: 14px;\n align-items: center;\n transition: background var(--t-base);\n cursor: pointer;\n position: relative;\n }\n .history-card:hover { background: var(--black-alpha-4); }\n .history-card:hover .card-del-btn { opacity: 1; }\n .history-card .placeholder { width: 78px; height: 78px; }\n\n /* ─── 列表视图 · 表格 ─── */\n #task-list-view {\n background: var(--surface);\n border: 1px solid var(--border-muted);\n border-radius: var(--r-md);\n overflow: hidden;\n }\n #task-list-view[hidden] { display: none; }\n #task-list-view table.t {\n border: 0;\n border-radius: 0;\n background: transparent;\n }\n #task-list-view table.t thead th {\n background: var(--background-lighter);\n border-bottom-color: var(--border-muted);\n }\n #task-list-view table.t tbody td {\n border-bottom: 0;\n }\n .task-name-cell { display: flex; align-items: center; gap: 12px; }\n .task-thumb { width: 40px; height: 40px; flex-shrink: 0; border-radius: var(--r-sm); }\n .task-name { font-weight: 600; color: var(--accent-black); font-size: 13.5px; }\n .task-sub { font-size: 11.5px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }\n table.t .task-list-prog { display: flex; align-items: center; gap: 8px; min-width: 120px; }\n table.t .task-list-prog .bar { flex: 1; height: 4px; background: var(--black-alpha-7); border-radius: 2px; overflow: hidden; }\n table.t .task-list-prog .bar span { display: block; height: 100%; background: var(--heat); border-radius: 2px; animation: hp-pulse 1.4s ease-in-out infinite; }\n table.t .task-list-prog .pct { font-family: var(--font-mono); font-size: 10.5px; color: var(--heat); letter-spacing: .02em; white-space: nowrap; }\n\n /* ─── 行末 ⋯ 删除气泡 (复用 projects.html) ─── */\n .row-action { display: flex; gap: 4px; justify-content: flex-end; }\n table.t tbody tr .row-more { opacity: 0; transition: opacity .15s; }\n table.t tbody tr:hover .row-more { opacity: 1; }\n .row-more {\n position: relative; display: inline-flex;\n cursor: pointer; align-items: center;\n color: var(--black-alpha-56);\n padding: 4px;\n }\n .row-more:hover { color: var(--accent-black); }\n .row-more-tip {\n position: absolute; top: calc(100% + 6px); right: 0;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n box-shadow: 0 4px 16px rgba(0,0,0,.08);\n padding: 4px; min-width: 110px;\n opacity: 0; pointer-events: none;\n transform: translateY(-2px);\n transition: opacity .15s, transform .15s;\n z-index: 12;\n }\n .row-more-tip::before {\n content: ''; position: absolute;\n top: -8px; left: 0; right: 0; height: 8px;\n }\n .row-more:hover .row-more-tip,\n .row-more-tip:hover { opacity: 1; pointer-events: auto; transform: translateY(0); }\n .row-more-tip .mi {\n display: flex; align-items: center; gap: 6px;\n width: 100%; padding: 6px 10px;\n background: transparent; border: 0;\n border-radius: var(--r-sm); cursor: pointer;\n font-size: 12.5px; color: var(--accent-black);\n font-family: inherit; text-align: left;\n transition: background var(--t-base), color var(--t-base);\n }\n .row-more-tip .mi:hover {\n background: var(--crimson-bg, #fdebea);\n color: var(--accent-crimson, #c43d3d);\n }\n .row-more-tip .mi svg { width: 13px; height: 13px; }\n\n /* ─── 删除确认 modal · 复用 ─── */\n .modal-bg.show { display: flex; }\n .mono-acc { font-family: var(--font-mono); color: var(--heat); font-weight: 600; }\n\n .history-body { min-width: 0; display: flex; flex-direction: column; gap: 4px; }\n .history-name {\n font-size: 13.5px; font-weight: 600; color: var(--accent-black);\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n }\n .history-type {\n font-size: 11.5px; color: var(--black-alpha-48);\n font-family: var(--font-mono); letter-spacing: .02em;\n }\n .history-foot {\n display: flex; align-items: center; justify-content: space-between;\n margin-top: 4px; gap: 6px; min-width: 0;\n }\n .history-foot .mono {\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n }\n .history-foot .pill { padding: 2px 8px; font-size: 10.5px; }\n .history-foot .pill .dot { width: 5px; height: 5px; }\n\n /* 进度条(生成中状态) */\n .history-prog {\n margin-top: 6px;\n display: flex; align-items: center; gap: 8px;\n }\n .history-prog .bar {\n flex: 1; height: 4px;\n background: var(--black-alpha-7);\n border-radius: 2px; overflow: hidden;\n }\n .history-prog .bar span {\n display: block; height: 100%;\n background: var(--heat); border-radius: 2px;\n animation: hp-pulse 1.4s ease-in-out infinite;\n }\n @keyframes hp-pulse {\n 0%, 100% { opacity: 1; transform: scaleY(1); }\n 50% { opacity: .55; transform: scaleY(.7); }\n }\n .history-prog .pct {\n font-family: var(--font-mono); font-size: 10px;\n color: var(--heat); letter-spacing: .02em; white-space: nowrap;\n }\n</style>\n</head>\n<body>\n<div id=\"page\">\n\n<div class=\"page-head\">\n <div>\n <h1>图片生成</h1>\n <div class=\"sub\">\n <span class=\"mono\">// 一键生成</span>\n <span>·</span>\n <span>电商视觉素材,提升内容制作效率</span>\n </div>\n </div>\n</div>\n\n<!-- 双 Hero 卡片 -->\n<div class=\"factory-hero\">\n\n <!-- 卡片 A · 模特上身图 -->\n <div class=\"factory-card with-corners\">\n <span class=\"corner-tr\" aria-hidden></span>\n <span class=\"corner-bl\" aria-hidden></span>\n\n <div class=\"factory-body\">\n <div class=\"factory-text\">\n <span class=\"factory-tag\">[ MODEL · TRY-ON ]</span>\n <div class=\"factory-title\">模特上身图</div>\n <div class=\"factory-desc\">选择模特,AI 生成商品模特上身效果图</div>\n\n <ul class=\"factory-features\">\n <li>\n <span class=\"ff-ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"9\" cy=\"9\" r=\"3\"/><circle cx=\"17\" cy=\"9\" r=\"2.5\"/><path d=\"M3 19c0-3 2.7-5 6-5s6 2 6 5M14 19c.5-2.4 2.4-4 5-4 .8 0 1.5.2 2 .5\"/></svg>\n </span>\n 支持多模特选择\n </li>\n <li>\n <span class=\"ff-ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"8\" height=\"8\"/><rect x=\"13\" y=\"3\" width=\"8\" height=\"8\"/><rect x=\"3\" y=\"13\" width=\"8\" height=\"8\"/><rect x=\"13\" y=\"13\" width=\"8\" height=\"8\"/></svg>\n </span>\n 一次生成 4 张\n </li>\n <li>\n <span class=\"ff-ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m3 7 9-4 9 4-9 4-9-4z\"/><path d=\"m3 12 9 4 9-4M3 17l9 4 9-4\"/></svg>\n </span>\n 支持多商品并行\n </li>\n </ul>\n\n <div class=\"factory-cta\">\n <a class=\"btn btn-primary btn-lg\" href=\"model-photo.html\">\n 开始生成\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12h14M12 5l7 7-7 7\"/></svg>\n </a>\n <span class=\"cost\">[ ≈ ¥0.30 / 张 ]</span>\n </div>\n </div>\n\n <div class=\"factory-visual model-visual\">\n <div class=\"placeholder main\"><span class=\"ph-frame\">Ava · 9:16</span></div>\n <div class=\"stack\">\n <div class=\"placeholder\"><span class=\"ph-frame\">变体 01</span></div>\n <div class=\"placeholder\"><span class=\"ph-frame\">变体 02</span></div>\n <div class=\"placeholder\"><span class=\"ph-frame\">变体 03</span></div>\n </div>\n </div>\n </div>\n </div>\n\n <!-- 卡片 B · 平台套图 -->\n <div class=\"factory-card with-corners\">\n <span class=\"corner-tr\" aria-hidden></span>\n <span class=\"corner-bl\" aria-hidden></span>\n\n <div class=\"factory-body\">\n <div class=\"factory-text\">\n <span class=\"factory-tag\">[ PLATFORM · KIT ]</span>\n <div class=\"factory-title\">平台套图</div>\n <div class=\"factory-desc\">选择平台模板,AI 生成电商平台套图</div>\n\n <ul class=\"factory-features\">\n <li>\n <span class=\"ff-ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 21V9M9 21V5M15 21v-8M21 21V11\"/></svg>\n </span>\n 覆盖主流电商平台\n </li>\n <li>\n <span class=\"ff-ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"8\" height=\"8\"/><rect x=\"13\" y=\"3\" width=\"8\" height=\"8\"/><rect x=\"3\" y=\"13\" width=\"8\" height=\"8\"/><rect x=\"13\" y=\"13\" width=\"8\" height=\"8\"/></svg>\n </span>\n 一键生成 4 张套图\n </li>\n <li>\n <span class=\"ff-ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z\"/></svg>\n </span>\n 智能排版设计\n </li>\n </ul>\n\n <div class=\"factory-cta\">\n <a class=\"btn btn-primary btn-lg\" href=\"platform-cover.html\">\n 开始生成\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12h14M12 5l7 7-7 7\"/></svg>\n </a>\n <span class=\"cost\">[ ≈ ¥0.50 / 张 ]</span>\n </div>\n </div>\n\n <div class=\"factory-visual kit-visual\">\n <div class=\"placeholder\"><span class=\"ph-frame\">套图 / TB</span></div>\n <div class=\"placeholder\"><span class=\"ph-frame\">套图 / DY</span></div>\n <div class=\"placeholder\"><span class=\"ph-frame\">套图 / XHS</span></div>\n <div class=\"placeholder\"><span class=\"ph-frame\">套图 / PDD</span></div>\n </div>\n </div>\n </div>\n\n <!-- 卡片 C · 图片创作(自由创作 AI 图片工作台)-->\n <div class=\"factory-card with-corners\">\n <span class=\"corner-tr\" aria-hidden></span>\n <span class=\"corner-bl\" aria-hidden></span>\n\n <div class=\"factory-body\">\n <div class=\"factory-text\">\n <span class=\"factory-tag\">[ IMAGE · STUDIO ]</span>\n <div class=\"factory-title\">图片创作</div>\n <div class=\"factory-desc\">自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写</div>\n\n <ul class=\"factory-features\">\n <li>\n <span class=\"ff-ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"9\" cy=\"9\" r=\"3\"/><path d=\"M3 19c0-3 2.7-5 6-5s6 2 6 5\"/></svg>\n </span>\n 人物 · 商品 全支持\n </li>\n <li>\n <span class=\"ff-ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"6\" height=\"18\"/><rect x=\"9\" y=\"3\" width=\"6\" height=\"18\"/><rect x=\"15\" y=\"3\" width=\"6\" height=\"18\"/></svg>\n </span>\n 正面 / 侧面 / 背面 一次输出\n </li>\n <li>\n <span class=\"ff-ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83\"/></svg>\n </span>\n 多镜头一致性保证\n </li>\n </ul>\n\n <div class=\"factory-cta\">\n <a class=\"btn btn-primary btn-lg\" href=\"image-optimize.html\">\n 开始生成\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12h14M12 5l7 7-7 7\"/></svg>\n </a>\n <span class=\"cost\">[ ≈ ¥0.40 / 组 ]</span>\n </div>\n </div>\n\n <div class=\"factory-visual tri-visual\">\n <div class=\"placeholder\"><span class=\"ph-frame\">正 / 侧 / 背 · 三视图</span></div>\n </div>\n </div>\n </div>\n\n</div>\n\n<!-- ============= 任务中心 · 参考 projects.html 布局 ============= -->\n<div class=\"section-h\">\n <h2>任务中心</h2>\n <span class=\"sub-mono\">// <span id=\"tc-sub-total\">0</span> 个 · <span id=\"tc-sub-gen\">0</span> 生成中 · <span id=\"tc-sub-ok\">0</span> 已完成 · <span id=\"tc-sub-err\">0</span> 失败</span>\n</div>\n\n<!-- 状态 tabs (复用 .tabs) -->\n<div class=\"tabs\" id=\"tc-tabs\">\n <div class=\"tab active\" data-filter=\"all\">全部 <span class=\"count\">0</span></div>\n <div class=\"tab\" data-filter=\"gen\">生成中 <span class=\"count\">0</span></div>\n <div class=\"tab\" data-filter=\"ok\">已完成 <span class=\"count\">0</span></div>\n <div class=\"tab\" data-filter=\"err\">失败 <span class=\"count\">0</span></div>\n</div>\n\n<!-- toolbar: search + 类型 chip + clear + view-toggle -->\n<div class=\"toolbar\">\n <div class=\"search-inline\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/></svg>\n <input class=\"input\" id=\"tc-search\" placeholder=\"搜索任务名\">\n </div>\n <div class=\"chip-wrap\" data-key=\"time\">\n <button class=\"chip\" type=\"button\"><span class=\"chip-label\">时间</span> <svg class=\"caret\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></button>\n <div class=\"chip-menu\">\n <div class=\"mi selected\" data-value=\"all\"><svg class=\"mi-check\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg><span>全部时间</span></div>\n <div class=\"mi-sep\"></div>\n <div class=\"mi\" data-value=\"today\"><svg class=\"mi-check\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg><span>今天</span></div>\n <div class=\"mi\" data-value=\"1h\"><svg class=\"mi-check\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg><span>1 小时内</span></div>\n <div class=\"mi\" data-value=\"10min\"><svg class=\"mi-check\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg><span>10 分钟内</span></div>\n </div>\n </div>\n <div class=\"chip-wrap\" data-key=\"type\">\n <button class=\"chip\" type=\"button\"><span class=\"chip-label\">任务类型</span> <svg class=\"caret\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></button>\n <div class=\"chip-menu\"></div>\n </div>\n <button class=\"clear-filters\" id=\"tc-clear\" type=\"button\" hidden>\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 4l8 8M12 4l-8 8\"/></svg>\n 清空筛选\n </button>\n <span class=\"spacer\"></span>\n <div class=\"view-toggle\" id=\"tc-view-toggle\">\n <button type=\"button\" data-view=\"grid\">\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><rect x=\"2\" y=\"2\" width=\"5\" height=\"5\"/><rect x=\"9\" y=\"2\" width=\"5\" height=\"5\"/><rect x=\"2\" y=\"9\" width=\"5\" height=\"5\"/><rect x=\"9\" y=\"9\" width=\"5\" height=\"5\"/></svg>\n 网格\n </button>\n <button type=\"button\" class=\"active\" data-view=\"list\">\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M2 4h12M2 8h12M2 12h12\"/></svg>\n 列表\n </button>\n </div>\n</div>\n\n<div class=\"result-meta\" id=\"tc-result-meta\">// 显示 <span class=\"count\">0</span> / 0 个任务</div>\n\n<!-- ============= LIST VIEW (默认) ============= -->\n<div id=\"task-list-view\">\n <table class=\"t\">\n <thead>\n <tr>\n <th style=\"width:42%\">任务</th>\n <th style=\"width:160px\">进度</th>\n <th>状态</th>\n <th style=\"width:120px\">创建于</th>\n <th style=\"width:48px\"></th>\n </tr>\n </thead>\n <tbody id=\"task-list-tbody\"><!-- JS 从卡片同步生成 --></tbody>\n </table>\n</div>\n\n<!-- ============= GRID VIEW (JS 从 localStorage 动态渲染) ============= -->\n<div class=\"history-grid\" id=\"task-grid\" hidden></div>\n\n<!-- 空态 -->\n<div id=\"tc-empty\" hidden style=\"padding:60px 20px;text-align:center;color:var(--black-alpha-48);font-size:13px;line-height:1.6\">\n <div style=\"font-family:var(--font-mono);font-size:11px;letter-spacing:.04em;margin-bottom:6px;\">// NO TASKS YET</div>\n <div id=\"tc-empty-text\">还没有任务,去上方选一个工序开始生成吧</div>\n</div>\n\n</div>\n\n<!-- ===== 删除确认 modal (复用 projects.html 风格) ===== -->\n<div class=\"modal-bg\" id=\"tc-del-bg\">\n <div class=\"modal\" role=\"dialog\">\n <span class=\"corner-tr\" aria-hidden></span>\n <span class=\"corner-bl\" aria-hidden></span>\n <div class=\"modal-h\">\n <div class=\"ic-m\" style=\"background:var(--crimson-bg,#fdebea);color:var(--accent-crimson,#c43d3d)\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg>\n </div>\n <div class=\"ti\">确认删除任务<span>// CONFIRM DELETE</span></div>\n </div>\n <div class=\"modal-b\" id=\"tc-del-body\">即将删除任务记录。</div>\n <div class=\"modal-f\">\n <button class=\"btn\" type=\"button\" id=\"tc-del-cancel\">取消</button>\n <button class=\"btn\" type=\"button\" id=\"tc-del-ok\" style=\"background:var(--accent-crimson);color:var(--accent-white);border-color:var(--accent-crimson)\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/></svg>\n 确认删除\n </button>\n </div>\n </div>\n</div>\n\n<script src=\"/exact/assets/icons.js?v=2026052608\"></script>\n<script src=\"/exact/assets/shell.js?v=2026060101\"></script>\n<script src=\"/exact/assets/new-product-drawer.js?v=202605211643\"></script>\n<script>\nShell.render({\n active: 'asset-factory',\n crumbs: [{ label: '工作台', href: 'index.html' }, { label: '图片生成' }]\n});\n\n/* ============================================================\n 任务中心 · 从 localStorage 读取(与 model-photo / platform-cover 共享)\n ============================================================ */\n(function () {\n 'use strict';\n\n const TYPE_LABEL = { model: '模特上身图', platform: '平台套图', image: '图片创作' };\n const STATUS_LABEL = { ok: '已完成', gen: '生成中', err: '失败' };\n const STATUS_PILL = { ok: 'ok', gen: 'info', err: 'err' };\n const KEY_BY_TYPE = {\n model: 'fs-image-tasks-model',\n platform: 'fs-image-tasks-platform',\n image: 'fs-image-tasks-image',\n };\n const URL_BY_TYPE = {\n model: 'model-photo.html',\n platform: 'platform-cover.html',\n image: 'image-optimize.html',\n };\n\n const taskGrid = document.getElementById('task-grid');\n const listTbody = document.getElementById('task-list-tbody');\n const gridView = taskGrid;\n const listView = document.getElementById('task-list-view');\n\n let cards = []; // 动态生成的 .task-card 元素数组(顺序与任务时间倒序一致)\n\n const state = { filter: 'all', type: 'all', time: 'all', search: '', view: 'list' };\n\n function _timeMatch(createdAt, key) {\n if (key === 'all' || !createdAt) return true;\n const now = Date.now();\n const diff = now - Number(createdAt);\n if (key === '10min') return diff <= 10 * 60 * 1000;\n if (key === '1h') return diff <= 60 * 60 * 1000;\n if (key === 'today') {\n const a = new Date(now); const b = new Date(Number(createdAt));\n return a.toDateString() === b.toDateString();\n }\n return true;\n }\n const TIME_LABEL = { all: '时间', today: '今天', '1h': '1 小时内', '10min': '10 分钟内' };\n\n function esc(s) { return String(s == null ? '' : s).replace(/[&<>\"']/g, c => ({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c])); }\n\n /* ---------- localStorage 读写 ---------- */\n function loadType(type) {\n try { return JSON.parse(localStorage.getItem(KEY_BY_TYPE[type]) || '[]'); } catch (e) { return []; }\n }\n function saveType(type, arr) {\n try { localStorage.setItem(KEY_BY_TYPE[type], JSON.stringify(arr)); } catch (e) {}\n }\n function loadAllTasks() {\n const all = [];\n Object.keys(KEY_BY_TYPE).forEach(type => {\n loadType(type).forEach(t => all.push({ ...t, type })); // type 兜底\n });\n all.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));\n return all;\n }\n\n /* ---------- task → 渲染辅助 ---------- */\n function subTextOf(t) {\n const lbl = TYPE_LABEL[t.type] || t.type;\n const count = (t.snap && t.snap.count) || 4;\n return `${lbl} · ${count} 张`;\n }\n function thumbLabelOf(t) {\n // 取「商品 × 模特/平台」中右半作为缩略图占位文字\n const parts = (t.name || '').split(/\\s[×x]\\s/);\n return (parts[1] || parts[0] || '—').slice(0, 8);\n }\n\n /* ---------- 点击行 / 卡片 → 跳转工作台(携带 taskId) ---------- */\n function goToWorkbench(t) {\n const base = URL_BY_TYPE[t.type] || URL_BY_TYPE.model;\n location.href = base + '?taskId=' + encodeURIComponent(t.id);\n }\n\n /* ---------- 1. 从 task 数据生成卡片 + list 行 ---------- */\n function cardFor(t) {\n const card = document.createElement('div');\n card.className = 'task-card history-card';\n card.dataset.status = t.status;\n card.dataset.type = t.type;\n card.dataset.name = t.name;\n card.dataset.taskId = t.id;\n card.dataset.createdAt = String(t.createdAt || 0);\n card.style.cursor = 'pointer';\n card.innerHTML = `\n <button class=\"card-del-btn\" type=\"button\" title=\"删除任务\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg>\n </button>\n <div class=\"placeholder\"><span class=\"ph-frame\">${esc(thumbLabelOf(t))}</span></div>\n <div class=\"history-body\">\n <div class=\"history-name\">${esc(t.name)}</div>\n <div class=\"history-type\">${esc(subTextOf(t))}</div>\n <div class=\"history-foot\">\n <span class=\"mono\">// ${esc(t.time || '')}</span>\n <span class=\"pill ${STATUS_PILL[t.status] || 'info'}\"><span class=\"dot\"></span>${esc(STATUS_LABEL[t.status] || t.status)}</span>\n </div>\n </div>\n `;\n return card;\n }\n\n function rowFor(t) {\n const tr = document.createElement('tr');\n tr.dataset.taskRow = '1';\n tr.dataset.name = t.name;\n tr.dataset.type = t.type;\n tr.dataset.status = t.status;\n tr.dataset.taskId = t.id;\n tr.addEventListener('click', () => goToWorkbench(t));\n tr.innerHTML = `\n <td>\n <div class=\"task-name-cell\">\n <div class=\"placeholder task-thumb\"><span class=\"ph-frame\">${esc(thumbLabelOf(t))}</span></div>\n <div>\n <div class=\"task-name\">${esc(t.name)}</div>\n <div class=\"task-sub\">${esc(subTextOf(t))}</div>\n </div>\n </div>\n </td>\n <td>${t.status === 'gen'\n ? `<div class=\"task-list-prog\"><div class=\"bar\"><span style=\"width:60%\"></span></div><span class=\"pct\">60%</span></div>`\n : (t.status === 'ok' ? '<span class=\"muted-2 mono\" style=\"font-size:11px;\">已完成</span>' : '<span class=\"muted-2 mono\" style=\"font-size:11px;\">—</span>')}</td>\n <td><span class=\"pill ${STATUS_PILL[t.status] || 'info'}\"><span class=\"dot\"></span>${esc(STATUS_LABEL[t.status] || t.status)}</span></td>\n <td class=\"muted-2\">${esc(t.time || '')}</td>\n <td>\n <div class=\"row-action\">\n <span class=\"row-more\"><svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\"><circle cx=\"3\" cy=\"8\" r=\"1.2\" fill=\"currentColor\"/><circle cx=\"8\" cy=\"8\" r=\"1.2\" fill=\"currentColor\"/><circle cx=\"13\" cy=\"8\" r=\"1.2\" fill=\"currentColor\"/></svg>\n <div class=\"row-more-tip\"><button class=\"mi mi-del-task\" type=\"button\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg>删除任务</button></div>\n </span>\n </div>\n </td>\n `;\n tr.querySelector('.row-more').addEventListener('click', e => e.stopPropagation());\n return tr;\n }\n\n /* ---------- 2. 全量重渲染(load → render → filter) ---------- */\n function renderAll() {\n // 清空 grid / list\n taskGrid.innerHTML = '';\n listTbody.innerHTML = '';\n cards = [];\n\n const tasks = loadAllTasks();\n tasks.forEach(t => {\n const card = cardFor(t);\n const row = rowFor(t);\n taskGrid.appendChild(card);\n listTbody.appendChild(row);\n card._listRow = row;\n card._task = t;\n row._card = card;\n cards.push(card);\n\n // 卡片点击 → 跳工作台\n card.addEventListener('click', e => {\n if (e.target.closest('.card-del-btn')) return;\n goToWorkbench(t);\n });\n // 卡片删除按钮\n card.querySelector('.card-del-btn').addEventListener('click', e => {\n e.stopPropagation();\n openDelConfirm(card);\n });\n });\n\n // 类型 chip 菜单(基于现有 task 的 type 集合,动态)\n rebuildTypeMenu();\n applyFilter();\n }\n\n /* ---------- 3. 构建类型 chip 菜单(基于当前 cards 的 type 集合) ---------- */\n const checkSvg = '<svg class=\"mi-check\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg>';\n const typeMenu = document.querySelector('.chip-wrap[data-key=\"type\"] .chip-menu');\n function rebuildTypeMenu() {\n const typeOptions = [...new Set(cards.map(c => c.dataset.type))];\n typeMenu.innerHTML = `<div class=\"mi selected\" data-value=\"all\">${checkSvg}<span>全部任务类型</span></div><div class=\"mi-sep\"></div>`\n + typeOptions.map(v => `<div class=\"mi\" data-value=\"${esc(v)}\">${checkSvg}<span>${esc(TYPE_LABEL[v] || v)}</span></div>`).join('');\n syncTypeChip();\n }\n\n function syncTypeChip() {\n const wrap = document.querySelector('.chip-wrap[data-key=\"type\"]');\n const label = wrap.querySelector('.chip-label');\n const chip = wrap.querySelector('.chip');\n if (state.type === 'all') {\n label.textContent = '任务类型';\n chip.classList.remove('active');\n } else {\n label.textContent = TYPE_LABEL[state.type] || state.type;\n chip.classList.add('active');\n }\n wrap.querySelectorAll('.mi').forEach(mi => mi.classList.toggle('selected', mi.dataset.value === state.type));\n }\n\n function syncTimeChip() {\n const wrap = document.querySelector('.chip-wrap[data-key=\"time\"]');\n if (!wrap) return;\n const label = wrap.querySelector('.chip-label');\n const chip = wrap.querySelector('.chip');\n if (state.time === 'all') {\n label.textContent = '时间';\n chip.classList.remove('active');\n } else {\n label.textContent = TIME_LABEL[state.time] || state.time;\n chip.classList.add('active');\n }\n wrap.querySelectorAll('.mi').forEach(mi => mi.classList.toggle('selected', mi.dataset.value === state.time));\n }\n\n /* ---------- 3. applyFilter ---------- */\n function applyFilter() {\n const q = state.search.toLowerCase();\n let visible = 0;\n cards.forEach(card => {\n const okStatus = state.filter === 'all' || card.dataset.status === state.filter;\n const okType = state.type === 'all' || card.dataset.type === state.type;\n const okTime = _timeMatch(card.dataset.createdAt, state.time);\n const okSearch = !q || (card.dataset.name || '').toLowerCase().includes(q);\n const show = okStatus && okType && okTime && okSearch;\n card.style.display = show ? '' : 'none';\n if (card._listRow) card._listRow.style.display = show ? '' : 'none';\n if (show) visible++;\n });\n\n // 计数\n const counts = { all: cards.length, gen: 0, ok: 0, err: 0 };\n cards.forEach(c => { if (counts[c.dataset.status] !== undefined) counts[c.dataset.status]++; });\n document.querySelectorAll('#tc-tabs .tab').forEach(t => {\n const f = t.dataset.filter;\n t.querySelector('.count').textContent = f === 'all' ? counts.all : counts[f];\n });\n document.getElementById('tc-sub-total').textContent = counts.all;\n document.getElementById('tc-sub-gen').textContent = counts.gen;\n document.getElementById('tc-sub-ok').textContent = counts.ok;\n document.getElementById('tc-sub-err').textContent = counts.err;\n\n document.getElementById('tc-result-meta').innerHTML = `// 显示 <span class=\"count\">${visible}</span> / ${cards.length} 个任务`;\n document.getElementById('tc-clear').hidden = !(state.search || state.type !== 'all' || state.time !== 'all');\n\n // 空态\n const emptyEl = document.getElementById('tc-empty');\n const emptyText = document.getElementById('tc-empty-text');\n if (cards.length === 0) {\n emptyEl.hidden = false;\n emptyText.textContent = '还没有任务,去上方选一个工序开始生成吧';\n listView.hidden = true; gridView.hidden = true;\n } else if (visible === 0) {\n emptyEl.hidden = false;\n emptyText.textContent = '没有符合筛选条件的任务';\n // 视图本身保留,空表头也保留\n } else {\n emptyEl.hidden = true;\n // 恢复 view 显示(可能在 length===0 分支被隐藏)\n if (state.view === 'list') { listView.hidden = false; gridView.hidden = true; }\n else { listView.hidden = true; gridView.hidden = false; }\n }\n }\n\n /* ---------- 4. 事件绑定 ---------- */\n // status tabs\n document.querySelectorAll('#tc-tabs .tab').forEach(t => {\n t.addEventListener('click', () => {\n document.querySelectorAll('#tc-tabs .tab').forEach(x => x.classList.remove('active'));\n t.classList.add('active');\n state.filter = t.dataset.filter;\n applyFilter();\n });\n });\n // search\n document.getElementById('tc-search').addEventListener('input', e => {\n state.search = e.target.value.trim();\n applyFilter();\n });\n // type chip\n const typeWrap = document.querySelector('.chip-wrap[data-key=\"type\"]');\n typeWrap.querySelector('.chip').addEventListener('click', e => {\n e.stopPropagation();\n const isOpen = typeWrap.classList.contains('open');\n document.querySelectorAll('.chip-wrap.open').forEach(w => w.classList.remove('open'));\n if (!isOpen) typeWrap.classList.add('open');\n });\n typeMenu.addEventListener('click', e => {\n const mi = e.target.closest('.mi');\n if (!mi) return;\n e.stopPropagation();\n state.type = mi.dataset.value;\n typeWrap.classList.remove('open');\n syncTypeChip();\n applyFilter();\n });\n // time chip\n const timeWrap = document.querySelector('.chip-wrap[data-key=\"time\"]');\n const timeMenu = timeWrap.querySelector('.chip-menu');\n timeWrap.querySelector('.chip').addEventListener('click', e => {\n e.stopPropagation();\n const isOpen = timeWrap.classList.contains('open');\n document.querySelectorAll('.chip-wrap.open').forEach(w => w.classList.remove('open'));\n if (!isOpen) timeWrap.classList.add('open');\n });\n timeMenu.addEventListener('click', e => {\n const mi = e.target.closest('.mi');\n if (!mi) return;\n e.stopPropagation();\n state.time = mi.dataset.value;\n timeWrap.classList.remove('open');\n syncTimeChip();\n applyFilter();\n });\n document.addEventListener('click', () => {\n document.querySelectorAll('.chip-wrap.open').forEach(w => w.classList.remove('open'));\n });\n // clear filters\n document.getElementById('tc-clear').addEventListener('click', () => {\n state.search = '';\n state.type = 'all';\n state.time = 'all';\n document.getElementById('tc-search').value = '';\n syncTypeChip();\n syncTimeChip();\n applyFilter();\n Shell.toast('已清空筛选');\n });\n // view toggle\n document.querySelectorAll('#tc-view-toggle button').forEach(b => {\n b.addEventListener('click', () => {\n document.querySelectorAll('#tc-view-toggle button').forEach(x => x.classList.remove('active'));\n b.classList.add('active');\n state.view = b.dataset.view;\n if (state.view === 'list') { listView.hidden = false; gridView.hidden = true; }\n else { listView.hidden = true; gridView.hidden = false; }\n });\n });\n\n /* ---------- 6. 删除 modal + 同步 localStorage ---------- */\n const delBg = document.getElementById('tc-del-bg');\n const delBody = document.getElementById('tc-del-body');\n const delCancel = document.getElementById('tc-del-cancel');\n const delOk = document.getElementById('tc-del-ok');\n let _delTarget = null;\n\n function openDelConfirm(target) {\n _delTarget = target;\n const name = target.dataset.name || '该任务';\n delBody.innerHTML = '即将删除任务 <span class=\"mono-acc\">' + esc(name) + '</span>。任务记录将清除,已入库的素材不受影响。';\n delBg.classList.add('show');\n }\n function closeDelConfirm() { delBg.classList.remove('show'); _delTarget = null; }\n delCancel.addEventListener('click', closeDelConfirm);\n delBg.addEventListener('click', e => { if (e.target === delBg) closeDelConfirm(); });\n delOk.addEventListener('click', () => {\n if (!_delTarget) return;\n const card = _delTarget._card || _delTarget; // 可能传 row 或 card\n const taskId = card.dataset.taskId;\n const taskType = card.dataset.type;\n const name = card.dataset.name;\n // 从 localStorage 移除对应任务\n if (taskType && KEY_BY_TYPE[taskType]) {\n const arr = loadType(taskType).filter(t => t.id !== taskId);\n saveType(taskType, arr);\n }\n closeDelConfirm();\n Shell.toast('已删除', name);\n renderAll();\n });\n\n // 列表行 删除按钮(事件委托,因为是动态生成)\n listTbody.addEventListener('click', e => {\n const btn = e.target.closest('.mi-del-task');\n if (!btn) return;\n e.stopPropagation();\n const tr = btn.closest('tr');\n if (tr) openDelConfirm(tr);\n });\n\n /* ---------- 7. 初始化 ---------- */\n renderAll();\n})();\n</script>\n<script src=\"/exact/assets/api-bridge.js?v=2026060114\"></script>\n</body>\n</html>\n",
|
||
"dashboard": "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta name=\"x-airshelf-exact-source\" content=\"index.html\">\n<meta charset=\"utf-8\">\n<title>工作台 · Airshelf</title>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"/exact/assets/restraint.css?v=2026060101\">\n<style>\n .dash-grid { display: grid; grid-template-columns: 1.7fr 1fr; gap: 24px; align-items: start; }\n .recent-row { display: grid; grid-template-columns: 54px 1fr 110px 130px 60px; align-items: center; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--border-faint); cursor: pointer; }\n .recent-row .prog, .recent-row .pill, .recent-row .btn { justify-self: start; }\n .recent-row:last-child { border-bottom: 0; }\n .recent-row:hover { background: var(--background-lighter); }\n .recent-row .thumb { width: 54px; height: 70px; border-radius: var(--r-md); }\n .recent-meta .name { font-weight: 600; font-size: 13.5px; color: var(--accent-black); }\n .recent-meta .sub { font-size: 12px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .01em; }\n .shortcuts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }\n .shortcut { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 16px; display: flex; align-items: flex-start; gap: 12px; cursor: pointer; transition: background var(--t-base); }\n .shortcut:hover { background: var(--black-alpha-4); }\n .shortcut .ic { width: 32px; height: 32px; background: var(--heat-12); color: var(--heat); display: grid; place-items: center; border-radius: var(--r-md); flex-shrink: 0; }\n .shortcut .ic svg { width: 16px; height: 16px; }\n .shortcut .t { font-size: 13px; font-weight: 600; }\n .shortcut .d { font-size: 11.5px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .01em; }\n .tip { background: var(--surface); border: 1px dashed var(--border-faint); padding: 14px 16px; font-size: 12.5px; color: var(--black-alpha-56); line-height: 1.6; border-radius: var(--r-md); }\n .tip strong { color: var(--accent-black); font-weight: 600; display: block; margin-bottom: 4px; }\n .tip .mono { font-family: var(--font-mono); color: var(--heat); background: var(--heat-12); padding: 1px 5px; border-radius: var(--r-sm); font-size: 11.5px; }\n</style>\n</head>\n<body>\n<div id=\"page\">\n\n<div class=\"page-head\">\n <div>\n <h1>欢迎回来,小李</h1>\n <div class=\"sub\">\n <span class=\"mono\">// 05.14 · 周三</span>\n <span>·</span>\n <span>你有 <b style=\"color:var(--accent-black)\">3 个项目</b> 正在进行中</span>\n </div>\n </div>\n <div class=\"actions\">\n <a class=\"btn btn-create\" href=\"javascript:void(0)\" onclick=\"event.preventDefault(); window.NewProductDrawer && NewProductDrawer.open();\">\n <span data-iconkit=\"productPlus\" data-icon-size=\"16\"></span>\n 新建商品\n </a>\n <a class=\"btn btn-primary btn-lg btn-create\" href=\"projects-new.html\">\n <span data-iconkit=\"clapperboard\" data-icon-size=\"16\"></span>\n 新建项目\n </a>\n </div>\n</div>\n\n<div class=\"stats with-corners\">\n <span class=\"corner-tr\" aria-hidden></span><span class=\"corner-bl\" aria-hidden></span>\n <a class=\"stat\" href=\"projects.html\">\n <div class=\"lbl\">总项目 <span class=\"badge\">ALL</span></div>\n <div class=\"v\">8</div>\n <div class=\"delta up\"><span data-iconkit=\"arrowUp\" data-icon-size=\"14\"></span> 本月 +3</div>\n </a>\n <a class=\"stat\" href=\"projects.html?filter=wip\">\n <div class=\"lbl\">进行中 <span class=\"badge\">WIP</span></div>\n <div class=\"v\">3</div>\n <div class=\"delta\">2 个待审核</div>\n </a>\n <a class=\"stat\" href=\"projects.html?filter=done\">\n <div class=\"lbl\">本月成片 <span class=\"badge\">DONE</span></div>\n <div class=\"v\">3</div>\n <div class=\"delta up\"><span data-iconkit=\"arrowUp\" data-icon-size=\"14\"></span> 较上月 +33%</div>\n </a>\n <a class=\"stat\" href=\"account.html\">\n <div class=\"lbl\">余额 <span class=\"badge\">¥</span></div>\n <div class=\"v\">¥327<small>.40</small></div>\n <div class=\"bar\"><span style=\"width:33%\"></span></div>\n <div class=\"sub\">已用 ¥162.60 / ¥500</div>\n </a>\n</div>\n\n<div class=\"dash-grid\">\n <div>\n <div class=\"section-h\">\n <h2>最近项目</h2>\n <a class=\"more\" href=\"projects.html\">[ ALL · 8 ]</a>\n </div>\n <div class=\"card-hard\">\n <a class=\"recent-row\" href=\"pipeline.html?product=%E9%80%8F%E7%9C%9F%E8%A1%A5%E6%B0%B4%E9%9D%A2%E8%86%9C#stage-3\">\n <div class=\"placeholder thumb\"><span class=\"ph-frame\">9:16</span></div>\n <div class=\"recent-meta\">\n <div class=\"name\">补水面膜 · 痛点种草 · v3</div>\n <div class=\"sub\">透真补水面膜 / AI 全生 / 7 镜</div>\n </div>\n <div class=\"prog\"><span class=\"done\"></span><span class=\"done\"></span><span class=\"cur\"></span><span></span><span></span></div>\n <span class=\"pill info\"><span class=\"dot\"></span>故事板 待确认</span>\n <span class=\"btn btn-sm\">继续</span>\n </a>\n <a class=\"recent-row\" href=\"pipeline.html?product=%E9%80%8F%E7%9C%9F%E9%98%B2%E6%99%92#stage-5\">\n <div class=\"placeholder thumb\"><span class=\"ph-frame\">9:16</span></div>\n <div class=\"recent-meta\">\n <div class=\"name\">透真防晒 · 通勤对比</div>\n <div class=\"sub\">透真防晒 / AI 全生 / 6 镜</div>\n </div>\n <div class=\"prog\"><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span></div>\n <span class=\"pill ok\"><span class=\"dot\"></span>已完成</span>\n <span class=\"btn btn-sm\">打开</span>\n </a>\n <a class=\"recent-row\" href=\"pipeline.html?product=Pro%204%20%E8%93%9D%E7%89%99%E8%80%B3%E6%9C%BA#stage-4\">\n <div class=\"placeholder thumb\"><span class=\"ph-frame\">9:16</span></div>\n <div class=\"recent-meta\">\n <div class=\"name\">蓝牙耳机 · 开箱测评</div>\n <div class=\"sub\">Pro 4 蓝牙耳机 / 自带脚本 / 6 镜</div>\n </div>\n <div class=\"prog\"><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span><span class=\"cur\"></span><span></span></div>\n <span class=\"pill info\"><span class=\"dot\"></span>视频生成 4/6</span>\n <span class=\"btn btn-sm\">继续</span>\n </a>\n <a class=\"recent-row\" href=\"pipeline.html?product=%E5%87%9D%E5%BD%A9%E7%AB%8B%E4%BD%93%E5%8F%A3%E7%BA%A2#stage-2\">\n <div class=\"placeholder thumb\"><span class=\"ph-frame\">9:16</span></div>\n <div class=\"recent-meta\">\n <div class=\"name\">春日新品 · 立体口红</div>\n <div class=\"sub\">凝彩立体口红 / 一句话 / 5 镜</div>\n </div>\n <div class=\"prog\"><span class=\"done\"></span><span class=\"cur\"></span><span></span><span></span><span></span></div>\n <span class=\"pill info\"><span class=\"dot\"></span>资产生成中</span>\n <span class=\"btn btn-sm\">继续</span>\n </a>\n <a class=\"recent-row\" href=\"pipeline.html?product=%E5%86%B7%E8%90%83%E5%92%96%E5%95%A1%E5%86%BB%E5%B9%B2#stage-3\">\n <div class=\"placeholder thumb\"><span class=\"ph-frame\">9:16</span></div>\n <div class=\"recent-meta\">\n <div class=\"name\">咖啡冻干 · 早八</div>\n <div class=\"sub\">冷萃咖啡冻干 / 一句话 / 5 镜</div>\n </div>\n <div class=\"prog\"><span class=\"done\"></span><span class=\"done\"></span><span class=\"fail\"></span><span></span><span></span></div>\n <span class=\"pill err\"><span class=\"dot\"></span>故事板失败</span>\n <span class=\"btn btn-sm\">查看</span>\n </a>\n </div>\n </div>\n\n <div style=\"display:flex;flex-direction:column;gap:24px\">\n <div>\n <div class=\"section-h\"><h2>快捷入口</h2><span class=\"more\">[ /shortcuts ]</span></div>\n <div class=\"shortcuts\">\n <a class=\"shortcut\" href=\"products.html\">\n <div class=\"ic\" data-iconkit=\"package\"></div>\n <div><div class=\"t\">商品库</div><div class=\"d\">7 SKU</div></div>\n </a>\n <a class=\"shortcut\" href=\"library.html\">\n <div class=\"ic\" data-iconkit=\"images\"></div>\n <div><div class=\"t\">资产库</div><div class=\"d\">人 8 · 景 14 · 片 8</div></div>\n </a>\n <a class=\"shortcut\" href=\"account.html\">\n <div class=\"ic\" data-iconkit=\"creditCard\"></div>\n <div><div class=\"t\">充值</div><div class=\"d\">¥327.40</div></div>\n </a>\n <a class=\"shortcut\" href=\"projects.html\">\n <div class=\"ic\" data-iconkit=\"clapperboard\"></div>\n <div><div class=\"t\">所有项目</div><div class=\"d\">8 个</div></div>\n </a>\n </div>\n </div>\n <div>\n <div class=\"section-h\"><h2>提示</h2><span class=\"more\">[ FAQ ]</span></div>\n <div class=\"tip\">\n <strong>扣费规则</strong>\n 生成失败、超时、用户重跑 — 均不扣费。仅在你点 <span class=\"mono\">[ 确认通过 ]</span> 时按 token 实际结算。\n </div>\n </div>\n </div>\n</div>\n\n</div>\n\n<script src=\"/exact/assets/icons.js?v=2026052608\"></script>\n<script src=\"/exact/assets/shell.js?v=2026060101\"></script>\n<script src=\"/exact/assets/new-product-drawer.js?v=202605211643\"></script>\n<script>\n document.querySelectorAll('[data-iconkit]').forEach(el => {\n el.innerHTML = IconKit.svg(el.dataset.iconkit, { size: Number(el.dataset.iconSize || 16) });\n });\n Shell.render({ active: 'dashboard', crumbs: [{ label: '工作台' }] });\n</script>\n<script src=\"/exact/assets/api-bridge.js?v=2026060114\"></script>\n</body>\n</html>\n",
|
||
"imageOptimize": "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta name=\"x-airshelf-exact-source\" content=\"image-optimize.html\">\n<meta charset=\"utf-8\">\n<title>图片创作 · Airshelf</title>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"/exact/assets/restraint.css?v=2026060101\">\n<style>\n /* viewport-fit · 工作台铺满 */\n .app { height: 100vh; overflow: hidden; }\n main { display: flex; flex-direction: column; min-height: 0; }\n #page-content { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0; }\n\n /* ===== 整体两栏 · 左会话列表 + 中央对话流 ===== */\n .io-app {\n flex: 1; min-height: 0;\n display: grid;\n grid-template-columns: 240px 1fr;\n transition: grid-template-columns var(--t-base);\n }\n .io-app.side-collapsed { grid-template-columns: 0 1fr; }\n @media (max-width: 1100px) {\n .io-app { grid-template-columns: 200px 1fr; }\n .io-app.side-collapsed { grid-template-columns: 0 1fr; }\n }\n\n /* ========== 左 · 会话栏 ========== */\n .io-side {\n border-right: 1px solid var(--border-faint);\n background: var(--surface);\n display: flex; flex-direction: column;\n min-height: 0; overflow: hidden;\n transition: opacity var(--t-base), transform var(--t-base);\n }\n .io-app.side-collapsed .io-side {\n opacity: 0;\n transform: translateX(-8px);\n pointer-events: none;\n }\n .io-side-h {\n display: flex; align-items: center; gap: 8px;\n padding: 14px 14px 10px;\n border-bottom: 1px solid var(--border-faint);\n }\n .io-side-h .ti { font-size: 13.5px; font-weight: 600; color: var(--accent-black); }\n .io-side-h .back-pill {\n display: inline-flex; align-items: center; gap: 6px;\n height: 34px; padding: 0 13px 0 11px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-pill);\n color: var(--accent-black);\n font-size: 13px; font-weight: 500;\n font-family: inherit;\n cursor: pointer;\n transition: background var(--t-base), border-color var(--t-base), color var(--t-base);\n }\n .io-side-h .back-pill:hover {\n background: var(--black-alpha-4);\n border-color: var(--black-alpha-24);\n color: var(--accent-black);\n }\n .io-side-h .back-pill svg { width: 14px; height: 14px; }\n .io-side-h .fold {\n margin-left: auto;\n width: 26px; height: 26px;\n background: transparent; border: 0; border-radius: var(--r-sm);\n display: grid; place-items: center;\n color: var(--black-alpha-48); cursor: pointer;\n transition: background var(--t-base), color var(--t-base);\n }\n .io-side-h .fold:hover { background: var(--black-alpha-4); color: var(--accent-black); }\n .io-side-h .fold svg { width: 14px; height: 14px; }\n .io-new-conv {\n margin: 10px 12px 0;\n height: 36px;\n display: inline-flex; align-items: center; gap: 8px;\n padding: 0 12px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n color: var(--accent-black);\n font-size: 13px; font-weight: 500;\n font-family: inherit;\n cursor: pointer;\n transition: border-color var(--t-base), background var(--t-base), color var(--t-base);\n }\n .io-new-conv:hover {\n border-color: var(--heat-20); background: var(--heat-12); color: var(--heat);\n }\n .io-new-conv svg { width: 13px; height: 13px; }\n .io-side-sec-h {\n margin: 16px 14px 6px;\n font-family: var(--font-mono); font-size: 10px;\n color: var(--black-alpha-48);\n letter-spacing: .08em;\n text-transform: uppercase;\n }\n .io-conv-list {\n flex: 1; min-height: 0;\n overflow-y: auto;\n padding: 0 6px 14px;\n display: flex; flex-direction: column; gap: 2px;\n }\n .io-conv-item {\n display: flex; align-items: center; gap: 10px;\n padding: 8px 10px;\n border-radius: var(--r-sm);\n cursor: pointer;\n color: var(--accent-black);\n transition: background var(--t-base);\n position: relative;\n }\n .io-conv-item:hover { background: var(--background-lighter); }\n .io-conv-item.active { background: var(--heat-12); }\n .io-conv-item .thumb {\n flex-shrink: 0;\n width: 28px; height: 28px;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n background-size: cover; background-position: center;\n display: grid; place-items: center;\n color: var(--black-alpha-32);\n }\n .io-conv-item .thumb svg { width: 13px; height: 13px; }\n .io-conv-item.default .thumb {\n background: var(--accent-black); color: var(--accent-white); border-color: var(--accent-black);\n }\n .io-conv-item .nm {\n flex: 1; min-width: 0;\n font-size: 12.5px;\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n }\n .io-conv-item.active .nm { color: var(--heat); font-weight: 600; }\n .io-conv-item .del {\n display: none;\n width: 22px; height: 22px;\n background: transparent; border: 0; border-radius: var(--r-sm);\n color: var(--black-alpha-48); cursor: pointer;\n align-items: center; justify-content: center;\n }\n .io-conv-item:hover .del { display: inline-flex; }\n .io-conv-item .del:hover { color: var(--accent-crimson); background: var(--crimson-bg, #fdebea); }\n .io-conv-item .del svg { width: 11px; height: 11px; }\n\n /* ========== 右 · 对话流 ========== */\n .io-main {\n display: flex; flex-direction: column;\n min-height: 0;\n position: relative;\n }\n .io-toolbar {\n flex-shrink: 0;\n display: flex; align-items: center; gap: 10px;\n padding: 12px 28px;\n border-bottom: 1px solid var(--border-faint);\n background: var(--surface);\n }\n .io-toolbar .spacer { flex: 1; }\n .io-toolbar .side-restore-btn {\n height: 32px;\n padding: 0 10px;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--black-alpha-72);\n font-family: inherit;\n font-size: 12.5px;\n cursor: pointer;\n }\n .io-toolbar .side-restore-btn:hover { border-color: var(--heat-20); color: var(--heat); }\n .io-toolbar .side-restore-btn[hidden] { display: none; }\n .io-toolbar .side-restore-btn svg { width: 14px; height: 14px; }\n .io-toolbar-search {\n position: relative;\n width: min(320px, 32vw);\n min-width: 220px;\n }\n .io-toolbar-search[hidden] { display: none; }\n .io-toolbar-search svg {\n position: absolute;\n left: 10px;\n top: 50%;\n transform: translateY(-50%);\n width: 13px;\n height: 13px;\n color: var(--black-alpha-48);\n pointer-events: none;\n }\n .io-toolbar-search input {\n width: 100%;\n height: 32px;\n padding: 0 32px 0 30px;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--accent-black);\n font-family: inherit;\n font-size: 12.5px;\n outline: none;\n }\n .io-toolbar-search input:focus { border-color: var(--heat-40); background: var(--surface); }\n .io-toolbar-search .clear-search {\n position: absolute;\n right: 4px;\n top: 50%;\n transform: translateY(-50%);\n width: 24px;\n height: 24px;\n border: 0;\n border-radius: var(--r-sm);\n background: transparent;\n color: var(--black-alpha-48);\n cursor: pointer;\n display: grid;\n place-items: center;\n }\n .io-toolbar-search .clear-search:hover { background: var(--black-alpha-4); color: var(--accent-black); }\n .io-toolbar-search .clear-search svg {\n position: static;\n transform: none;\n width: 12px;\n height: 12px;\n }\n .io-toolbar .search-btn {\n width: 32px; height: 32px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--black-alpha-72);\n cursor: pointer;\n display: grid; place-items: center;\n }\n .io-toolbar .search-btn:hover { border-color: var(--heat-20); color: var(--heat); }\n .io-toolbar .search-btn.active { background: var(--heat-12); border-color: var(--heat-20); color: var(--heat); }\n .io-toolbar .search-btn svg { width: 14px; height: 14px; }\n .io-tool-filter { position: relative; display: inline-flex; }\n .io-toolbar .tb-chip {\n display: inline-flex; align-items: center; gap: 6px;\n height: 32px; padding: 0 10px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n font-size: 12.5px; color: var(--black-alpha-72);\n font-family: inherit; cursor: pointer;\n transition: border-color var(--t-base), color var(--t-base);\n }\n .io-toolbar .tb-chip:hover { border-color: var(--heat-20); color: var(--heat); }\n .io-toolbar .tb-chip.active { background: var(--heat-12); border-color: var(--heat-20); color: var(--heat); }\n .io-toolbar .tb-chip svg { width: 10px; height: 10px; opacity: .6; }\n .io-tool-filter.open .tb-chip svg { transform: rotate(180deg); }\n .io-tool-menu {\n position: absolute;\n top: calc(100% + 4px);\n right: 0;\n min-width: 156px;\n display: none;\n padding: 4px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n box-shadow: var(--shadow-floating);\n z-index: 30;\n }\n .io-tool-filter.open .io-tool-menu { display: block; }\n .io-tool-menu .mi {\n width: 100%;\n min-height: 32px;\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 0 10px;\n border: 0;\n border-radius: var(--r-sm);\n background: transparent;\n color: var(--accent-black);\n font-family: inherit;\n font-size: 12.5px;\n text-align: left;\n cursor: pointer;\n }\n .io-tool-menu .mi:hover { background: var(--black-alpha-4); }\n .io-tool-menu .mi.selected { background: var(--heat-12); color: var(--heat); font-weight: 500; }\n .io-tool-menu .mi svg {\n width: 12px;\n height: 12px;\n opacity: 0;\n color: var(--heat);\n }\n .io-tool-menu .mi.selected svg { opacity: 1; }\n\n /* 对话流主体 */\n .io-stream {\n flex: 1; min-height: 0;\n overflow-y: auto;\n padding: 28px 28px 220px; /* 底部留出输入框高度(避免被遮挡) */\n background: var(--background-base);\n }\n @media (max-width: 1100px) { .io-stream { padding: 22px 18px 220px; } }\n .io-stream-inner {\n max-width: 1180px; margin: 0 auto;\n display: flex; flex-direction: column; gap: 32px;\n }\n\n /* 单条对话(气泡式 · 提示词块 + 结果网格) */\n .io-msg { display: flex; flex-direction: column; gap: 14px; }\n .io-msg-prompt {\n display: flex; align-items: flex-start; gap: 12px;\n padding-left: 4px;\n }\n .io-msg-prompt .quote {\n flex-shrink: 0;\n width: 28px; height: 28px;\n border-radius: var(--r-sm);\n background: var(--surface);\n border: 1px solid var(--border-faint);\n color: var(--heat);\n display: grid; place-items: center;\n font-family: var(--font-mono); font-size: 13px;\n }\n .io-msg-prompt .quote svg { width: 13px; height: 13px; }\n .io-msg-prompt .pt {\n flex: 1; min-width: 0;\n padding-top: 4px;\n }\n .io-msg-prompt .pt-text {\n font-size: 14px; color: var(--accent-black);\n line-height: 1.55;\n word-break: break-word;\n }\n .io-msg-prompt .pt-tags {\n margin-top: 8px;\n display: flex; flex-wrap: wrap; gap: 6px;\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n align-items: center;\n }\n .io-msg-prompt .pt-tags .meta-chip {\n padding: 2px 8px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n }\n .io-msg-prompt .pt-tags .sep { color: var(--black-alpha-24); }\n\n /* 结果网格 — 4 张/行 */\n .io-msg-grid {\n display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;\n }\n @media (max-width: 1280px) { .io-msg-grid { grid-template-columns: repeat(3, 1fr); } }\n @media (max-width: 900px) { .io-msg-grid { grid-template-columns: repeat(2, 1fr); } }\n\n .io-cell {\n position: relative;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n overflow: hidden;\n cursor: pointer;\n transition: border-color var(--t-base);\n }\n .io-cell:hover { border-color: var(--black-alpha-32); }\n .io-cell.r-1-1 { aspect-ratio: 1 / 1; }\n .io-cell.r-16-9 { aspect-ratio: 16 / 9; }\n .io-cell.r-9-16 { aspect-ratio: 9 / 16; }\n .io-cell.r-4-3 { aspect-ratio: 4 / 3; }\n .io-cell.r-3-4 { aspect-ratio: 3 / 4; }\n .io-cell .ph-frame {\n position: absolute; inset: 0;\n display: grid; place-items: center;\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-32); letter-spacing: .02em;\n background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);\n }\n .io-cell.gen .ph-frame { animation: io-pulse 1.4s ease-in-out infinite; }\n @keyframes io-pulse {\n 0%, 100% { opacity: 1; }\n 50% { opacity: .55; }\n }\n .io-cell.err {\n border-color: var(--accent-crimson, #c43d3d);\n }\n .io-cell.err .ph-frame {\n color: var(--accent-crimson, #c43d3d);\n background: rgba(196, 61, 61, .05);\n }\n /* 右上 hover 操作组 */\n .io-cell .cell-ops {\n position: absolute; top: 6px; right: 6px;\n display: flex; gap: 4px;\n opacity: 0;\n transition: opacity var(--t-base);\n }\n .io-cell:hover .cell-ops { opacity: 1; }\n .io-cell .cell-ops button {\n width: 26px; height: 26px;\n background: rgba(255, 255, 255, .92);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--accent-black);\n cursor: pointer;\n display: grid; place-items: center;\n backdrop-filter: blur(4px);\n transition: border-color var(--t-base), color var(--t-base);\n }\n .io-cell .cell-ops button:hover { border-color: var(--heat); color: var(--heat); }\n .io-cell .cell-ops button svg { width: 12px; height: 12px; }\n\n /* 更多 · 下拉气泡(删除 / 加入资产库) */\n .io-cell .cell-more-wrap { position: relative; }\n .io-cell .cell-more-menu {\n position: absolute; top: calc(100% + 4px); right: 0;\n min-width: 132px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n box-shadow: 0 6px 24px rgba(0,0,0,.10);\n padding: 4px;\n display: none;\n z-index: 12;\n }\n .io-cell .cell-more-wrap.open .cell-more-menu { display: block; }\n .io-cell .cell-more-menu button {\n width: 100%;\n display: inline-flex !important; align-items: center; gap: 8px;\n padding: 7px 10px !important;\n background: transparent !important;\n border: 0 !important; border-radius: var(--r-sm) !important;\n font-size: 12.5px;\n color: var(--accent-black) !important;\n font-family: inherit;\n text-align: left;\n cursor: pointer;\n backdrop-filter: none !important;\n height: auto !important;\n justify-content: flex-start !important;\n }\n .io-cell .cell-more-menu button:hover {\n background: var(--background-lighter) !important;\n color: var(--heat) !important;\n }\n .io-cell .cell-more-menu button.danger:hover {\n color: var(--accent-crimson) !important;\n background: var(--crimson-bg, #fdebea) !important;\n }\n .io-cell .cell-more-menu button svg { width: 13px !important; height: 13px !important; }\n\n /* 操作行(重新编辑 / 再次生成 / ...) */\n .io-msg-ops {\n display: flex; gap: 8px;\n padding-left: 4px;\n }\n .io-msg-ops button {\n display: inline-flex; align-items: center; gap: 6px;\n height: 30px; padding: 0 12px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n font-size: 12.5px;\n color: var(--accent-black);\n font-family: inherit;\n cursor: pointer;\n transition: border-color var(--t-base), color var(--t-base), background var(--t-base);\n }\n .io-msg-ops button:hover {\n border-color: var(--heat-20); color: var(--heat); background: var(--heat-12);\n }\n .io-msg-ops button.icon {\n width: 30px; padding: 0;\n justify-content: center;\n }\n .io-msg-ops button svg { width: 13px; height: 13px; }\n\n /* 操作行 · 更多气泡(全部加入资产库 / 删除该批结果) */\n .io-msg-ops .msg-more-wrap { position: relative; }\n .io-msg-ops .msg-more-menu {\n position: absolute; bottom: calc(100% + 6px); left: 0;\n min-width: 168px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n box-shadow: 0 6px 24px rgba(0,0,0,.10);\n padding: 4px;\n display: none;\n z-index: 12;\n }\n .io-msg-ops .msg-more-wrap.open .msg-more-menu { display: block; }\n .io-msg-ops .msg-more-menu button {\n width: 100%;\n display: inline-flex !important; align-items: center; gap: 8px;\n height: auto !important;\n padding: 7px 10px !important;\n background: transparent !important;\n border: 0 !important; border-radius: var(--r-sm) !important;\n font-size: 12.5px;\n color: var(--accent-black) !important;\n text-align: left;\n justify-content: flex-start !important;\n }\n .io-msg-ops .msg-more-menu button:hover {\n background: var(--background-lighter) !important;\n color: var(--heat) !important;\n }\n .io-msg-ops .msg-more-menu button.danger:hover {\n color: var(--accent-crimson) !important;\n background: var(--crimson-bg, #fdebea) !important;\n }\n .io-msg-ops .msg-more-menu button svg { width: 13px !important; height: 13px !important; flex-shrink: 0; }\n\n /* 重复加入资产库 · 确认弹窗 */\n .io-dup-modal-bg {\n position: fixed; inset: 0; z-index: 1200;\n background: rgba(21, 20, 15, .42);\n display: grid; place-items: center;\n padding: 16px;\n }\n .io-dup-modal-bg[hidden] { display: none; }\n .io-dup-modal {\n width: min(420px, 92vw);\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n box-shadow: 0 16px 48px rgba(21, 20, 15, .18);\n overflow: hidden;\n }\n .io-dup-modal .dh {\n display: flex; align-items: center; gap: 12px;\n padding: 18px 20px 14px;\n }\n .io-dup-modal .dh .ic {\n width: 36px; height: 36px; flex-shrink: 0;\n border-radius: var(--r-md);\n background: var(--heat-12); color: var(--heat);\n display: grid; place-items: center;\n }\n .io-dup-modal .dh .ic svg { width: 18px; height: 18px; }\n .io-dup-modal .dh .ti { display: flex; flex-direction: column; gap: 3px; min-width: 0; }\n .io-dup-modal .dh .ti strong { font-size: 14.5px; color: var(--accent-black); font-weight: 600; }\n .io-dup-modal .dh .ti .mono { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .io-dup-modal .df {\n display: flex; gap: 8px;\n padding: 0 20px 18px;\n justify-content: flex-end;\n }\n .io-dup-modal .df button {\n height: 32px; padding: 0 14px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n font-size: 12.5px; color: var(--accent-black);\n font-family: inherit; cursor: pointer;\n transition: border-color var(--t-base), background var(--t-base), color var(--t-base);\n }\n .io-dup-modal .df button:hover { border-color: var(--heat-20); background: var(--heat-12); color: var(--heat); }\n .io-dup-modal .df button.primary {\n background: var(--heat); border-color: var(--heat); color: var(--accent-white);\n }\n .io-dup-modal .df button.primary:hover { filter: brightness(1.05); color: var(--accent-white); }\n\n /* 空态 */\n .io-empty {\n flex: 1; min-height: 100%;\n display: flex; flex-direction: column;\n align-items: center; justify-content: center;\n gap: 16px;\n padding: 40px;\n color: var(--black-alpha-56);\n text-align: center;\n }\n .io-empty .badge {\n font-family: var(--font-mono); font-size: 11px;\n letter-spacing: .08em; color: var(--black-alpha-48);\n text-transform: uppercase;\n }\n .io-empty h2 {\n font-size: 22px; font-weight: 600;\n color: var(--accent-black);\n letter-spacing: -.015em;\n }\n .io-empty p { font-size: 13px; max-width: 460px; line-height: 1.6; }\n .io-empty .ic {\n width: 64px; height: 64px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n display: grid; place-items: center;\n color: var(--heat);\n }\n .io-empty .ic svg { width: 28px; height: 28px; }\n .io-empty .examples {\n margin-top: 10px;\n display: flex; flex-wrap: wrap; gap: 8px; justify-content: center;\n max-width: 720px;\n }\n .io-empty .examples .ex {\n padding: 6px 12px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-pill);\n font-size: 12px;\n color: var(--black-alpha-72);\n cursor: pointer;\n transition: border-color var(--t-base), color var(--t-base), background var(--t-base);\n }\n .io-empty .examples .ex:hover {\n border-color: var(--heat-20); color: var(--heat); background: var(--heat-12);\n }\n\n /* 浮动 \"回到底部\" 按钮 */\n .io-jump-bottom {\n position: absolute;\n bottom: 200px; left: 50%;\n transform: translateX(-50%) translateY(0);\n display: inline-flex; align-items: center; gap: 4px;\n padding: 6px 12px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-pill);\n font-size: 12px;\n color: var(--black-alpha-72);\n cursor: pointer;\n box-shadow: 0 4px 16px rgba(0,0,0,.06);\n z-index: 6;\n opacity: 0; pointer-events: none;\n transition: opacity var(--t-base), transform var(--t-base);\n }\n .io-jump-bottom.show {\n opacity: 1; pointer-events: auto;\n transform: translateX(-50%) translateY(-6px);\n }\n .io-jump-bottom:hover { color: var(--heat); border-color: var(--heat-20); }\n .io-jump-bottom svg { width: 12px; height: 12px; }\n\n /* ========== 底部 · fixed 输入栏 ========== */\n .io-input-wrap {\n position: absolute; left: 0; right: 0; bottom: 0;\n padding: 14px 28px 22px;\n background: linear-gradient(to bottom, transparent 0, var(--background-base) 24px);\n z-index: 5;\n }\n @media (max-width: 1100px) { .io-input-wrap { padding: 14px 18px 18px; } }\n .io-input {\n max-width: 1180px; margin: 0 auto;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: 18px;\n padding: 12px 14px 10px;\n display: flex; flex-direction: column; gap: 8px;\n box-shadow: 0 6px 24px rgba(0,0,0,.06);\n transition: border-color var(--t-base);\n }\n .io-input:focus-within { border-color: var(--heat-40); }\n /* 上行 · 参考图 + 加号按钮 (同一 flex 行, 视觉同尺寸) */\n .io-input-top {\n display: flex; align-items: center; gap: 8px;\n flex-wrap: wrap;\n }\n .io-input-top .add-btn {\n flex-shrink: 0;\n width: 64px; height: 64px;\n background: var(--background-lighter);\n border: 1px dashed var(--border-faint);\n border-radius: var(--r-md);\n display: grid; place-items: center;\n color: var(--black-alpha-56);\n cursor: pointer;\n transition: border-color var(--t-base), color var(--t-base), background var(--t-base);\n }\n .io-input-top .add-btn:hover { border-color: var(--heat-40); color: var(--heat); background: var(--heat-12); }\n .io-input-top .add-btn svg { width: 22px; height: 22px; }\n /* 中行 · textarea 满宽 */\n .io-input textarea#io-input-text {\n width: 100%;\n border: 0; outline: 0; resize: none;\n background: transparent;\n font-family: inherit;\n font-size: 14px;\n line-height: 1.5;\n color: var(--accent-black);\n min-height: 44px; max-height: 220px;\n padding: 4px 2px;\n }\n .io-input textarea#io-input-text::placeholder { color: var(--black-alpha-48); }\n /* 发送按钮 (放底栏右端) */\n .io-input .send-btn {\n flex-shrink: 0;\n width: 32px; height: 32px;\n background: var(--heat); color: var(--accent-white);\n border: 0; border-radius: var(--r-md);\n cursor: pointer;\n display: grid; place-items: center;\n transition: opacity var(--t-base), filter var(--t-base);\n margin-left: 8px;\n }\n .io-input .send-btn:hover { filter: brightness(1.05); }\n .io-input .send-btn:disabled { opacity: .4; cursor: not-allowed; }\n .io-input .send-btn svg { width: 15px; height: 15px; }\n\n /* 参考图缩略 · 容器扁平化, 让子项直接参与 .io-input-top 的 flex 行 */\n .io-input-refs { display: contents; }\n .io-input-ref {\n position: relative;\n width: 64px; height: 64px;\n border-radius: var(--r-md);\n overflow: hidden;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n flex-shrink: 0;\n }\n .io-input-ref img { width: 100%; height: 100%; object-fit: cover; }\n .io-input-ref .x {\n position: absolute; top: 3px; right: 3px;\n width: 18px; height: 18px;\n background: rgba(0,0,0,.7); color: var(--accent-white);\n border: 0; border-radius: 50%;\n display: grid; place-items: center;\n cursor: pointer;\n }\n .io-input-ref .x svg { width: 10px; height: 10px; }\n\n /* 输入栏底部 · 参数胶囊行 (比例 / 模型 / 张数) */\n .io-input-bottom {\n display: flex; align-items: center; gap: 6px;\n flex-wrap: wrap;\n }\n .io-input-bottom .param {\n position: relative;\n display: inline-flex; align-items: center; gap: 4px;\n height: 26px; padding: 0 9px;\n background: var(--background-lighter);\n border: 1px solid transparent;\n border-radius: var(--r-pill);\n font-size: 11.5px; color: var(--black-alpha-72);\n cursor: pointer;\n transition: border-color var(--t-base), color var(--t-base), background var(--t-base);\n }\n .io-input-bottom .param[hidden] { display: none; }\n .io-input-bottom .param:hover { background: var(--surface); border-color: var(--border-faint); }\n .io-input-bottom .param.active { background: var(--heat-12); color: var(--heat); }\n .io-input-bottom .param .lbl-mono {\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n margin-right: 1px;\n }\n .io-input-bottom .param.active .lbl-mono { color: var(--heat); }\n .io-input-bottom .param svg { width: 10px; height: 10px; opacity: .6; }\n .io-input-bottom .right-meta {\n margin-left: auto;\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n }\n .io-input-bottom .right-meta .val { color: var(--accent-black); }\n\n /* 参数下拉气泡 */\n .io-param-menu {\n position: absolute; bottom: calc(100% + 6px); left: -2px;\n min-width: 140px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n box-shadow: 0 6px 24px rgba(0,0,0,.08);\n padding: 4px;\n display: none;\n z-index: 30;\n }\n .io-input-bottom .param.open .io-param-menu { display: block; }\n .io-param-menu .mi {\n display: flex; align-items: center; gap: 8px;\n padding: 7px 10px;\n border-radius: var(--r-sm);\n font-size: 12.5px;\n color: var(--accent-black);\n cursor: pointer;\n }\n .io-param-menu .mi:hover { background: var(--background-lighter); }\n .io-param-menu .mi.selected { color: var(--heat); font-weight: 600; }\n .io-param-menu .mi .mi-sub {\n margin-left: auto;\n font-family: var(--font-mono); font-size: 10px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n }\n .io-param-menu .mi .mi-check {\n width: 12px; height: 12px; opacity: 0;\n }\n .io-param-menu .mi.selected .mi-check { opacity: 1; }\n\n /* 隐藏 file input */\n #io-file-input { display: none; }\n</style>\n</head>\n<body>\n<div id=\"page\">\n <div class=\"io-app\">\n\n <!-- ===== 左 · 会话列表 ===== -->\n <aside class=\"io-side\">\n <div class=\"io-side-h\">\n <button class=\"back-pill\" type=\"button\" onclick=\"history.length > 1 ? history.back() : location.href='asset-factory.html'\" title=\"返回\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M15 18l-6-6 6-6\"/></svg>\n <span>返回</span>\n </button>\n <button class=\"fold\" type=\"button\" title=\"折叠侧栏\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/><path d=\"M9 3v18\"/></svg>\n </button>\n </div>\n\n <button class=\"io-new-conv\" type=\"button\" id=\"io-new-conv\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 20h9\"/><path d=\"M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4z\"/></svg>\n 新对话\n </button>\n\n <div class=\"io-side-sec-h\">默认</div>\n <div class=\"io-conv-list\" style=\"flex: 0 0 auto;\">\n <div class=\"io-conv-item default active\" data-default>\n <div class=\"thumb\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/><path d=\"M3 16l5-5 4 4 3-3 6 6\"/></svg>\n </div>\n <span class=\"nm\">默认创作</span>\n </div>\n </div>\n\n <div class=\"io-side-sec-h\">最近</div>\n <div class=\"io-conv-list\" id=\"io-conv-list\">\n <!-- JS 渲染最近会话 -->\n </div>\n </aside>\n\n <!-- ===== 右 · 对话流 ===== -->\n <section class=\"io-main\">\n\n <div class=\"io-toolbar\">\n <button class=\"side-restore-btn\" type=\"button\" id=\"io-side-restore\" hidden title=\"展开会话栏\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/><path d=\"M9 3v18\"/></svg>\n 会话\n </button>\n <span class=\"spacer\"></span>\n <button class=\"search-btn\" type=\"button\" title=\"搜索\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/></svg>\n </button>\n <div class=\"io-toolbar-search\" id=\"io-toolbar-search\" hidden>\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/></svg>\n <input id=\"io-toolbar-search-input\" type=\"text\" placeholder=\"搜索当前对话结果\">\n <button class=\"clear-search\" type=\"button\" id=\"io-toolbar-search-clear\" title=\"清空搜索\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"><path d=\"M18 6 6 18M6 6l12 12\"/></svg>\n </button>\n </div>\n <div class=\"io-tool-filter\" data-filter=\"time\">\n <button class=\"tb-chip\" type=\"button\"><span data-filter-label>时间</span><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></button>\n <div class=\"io-tool-menu\" data-filter-menu></div>\n </div>\n <div class=\"io-tool-filter\" data-filter=\"mode\">\n <button class=\"tb-chip\" type=\"button\"><span data-filter-label>生成模式</span><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></button>\n <div class=\"io-tool-menu\" data-filter-menu></div>\n </div>\n <div class=\"io-tool-filter\" data-filter=\"action\">\n <button class=\"tb-chip\" type=\"button\"><span data-filter-label>操作类型</span><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></button>\n <div class=\"io-tool-menu\" data-filter-menu></div>\n </div>\n </div>\n\n <div class=\"io-stream\" id=\"io-stream\">\n <div class=\"io-stream-inner\" id=\"io-stream-inner\">\n <!-- JS 渲染对话流 / 空态 -->\n </div>\n </div>\n\n <button class=\"io-jump-bottom\" type=\"button\" id=\"io-jump-bottom\">\n 回到底部\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 6l4 4 4-4\"/></svg>\n </button>\n\n <!-- 底部输入栏 -->\n <div class=\"io-input-wrap\">\n <div class=\"io-input\">\n <!-- 上行 · 参考图 + 加号按钮 (同一行, 64×64 同尺寸) -->\n <div class=\"io-input-top\">\n <div class=\"io-input-refs\" id=\"io-input-refs\"></div>\n <button class=\"add-btn\" type=\"button\" id=\"io-add-btn\" title=\"上传参考图\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 5v14M5 12h14\"/></svg>\n </button>\n <input type=\"file\" id=\"io-file-input\" accept=\"image/*\" multiple>\n </div>\n\n <!-- 中行 · textarea 满宽 -->\n <textarea id=\"io-input-text\" rows=\"1\" placeholder=\"输入想法、剧本或上传参考,支持 “/” 使用技能, @ 添加主体, 和 Agent 一起创作\"></textarea>\n\n <!-- 参数胶囊行 -->\n <div class=\"io-input-bottom\">\n\n <div class=\"param\" data-param=\"model\" tabindex=\"0\" hidden>\n <span class=\"lbl-mono\">模型</span>\n <span id=\"io-param-model-lbl\">Airshelf v2</span>\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg>\n <div class=\"io-param-menu\" id=\"io-menu-model\"></div>\n </div>\n\n <div class=\"param\" data-param=\"ratio\" tabindex=\"0\">\n <span class=\"lbl-mono\">比例</span>\n <span id=\"io-param-ratio-lbl\">1:1</span>\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg>\n <div class=\"io-param-menu\" id=\"io-menu-ratio\"></div>\n </div>\n\n <div class=\"param\" data-param=\"style\" tabindex=\"0\">\n <span class=\"lbl-mono\">风格</span>\n <span id=\"io-param-style-lbl\">默认</span>\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg>\n <div class=\"io-param-menu\" id=\"io-menu-style\"></div>\n </div>\n\n <div class=\"param\" data-param=\"count\" tabindex=\"0\">\n <span class=\"lbl-mono\">张数</span>\n <span id=\"io-param-count-lbl\">4</span>\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg>\n <div class=\"io-param-menu\" id=\"io-menu-count\"></div>\n </div>\n\n <span class=\"right-meta\">预估 <span class=\"val\" id=\"io-cost-val\">¥0.40</span> · 余额 <span class=\"val\">¥327.40</span></span>\n <button class=\"send-btn\" type=\"button\" id=\"io-send-btn\" disabled title=\"生成\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12h14M12 5l7 7-7 7\"/></svg>\n </button>\n </div>\n </div>\n </div>\n\n </section>\n </div>\n</div>\n\n<!-- ===== 重复加入资产库 · 确认弹窗 ===== -->\n<div class=\"io-dup-modal-bg\" id=\"io-dup-bg\" hidden>\n <div class=\"io-dup-modal\">\n <div class=\"dh\">\n <div class=\"ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 8v4M12 16h.01\"/></svg>\n </div>\n <div class=\"ti\">\n <strong id=\"io-dup-title\">图片已在资产库</strong>\n <span id=\"io-dup-sub\" class=\"mono\">// 选择处理方式</span>\n </div>\n </div>\n <div class=\"df\">\n <button type=\"button\" data-act=\"cancel\">取消</button>\n <button type=\"button\" data-act=\"dup\">新增副本</button>\n <button type=\"button\" class=\"primary\" data-act=\"overwrite\">覆盖原图</button>\n </div>\n </div>\n</div>\n\n<script src=\"/exact/assets/icons.js?v=2026052608\"></script>\n<script src=\"/exact/assets/shell.js?v=2026060101\"></script>\n<script>\nShell.render({\n active: 'asset-factory',\n crumbs: [\n { label: '工作台', href: 'index.html' },\n { label: '图片生成', href: 'asset-factory.html' },\n { label: '图片创作' }\n ]\n});\n</script>\n\n<script>\n/* ============================================================\n 图片创作 · 即梦风格 chat 工作台\n ----------------------------------------------------------\n 持久化:localStorage['fs-io-chat']\n 结构:[{ id, title, messages:[ {id, prompt, model, ratio, style, count,\n refImages:[{id,name}], results:[{id,status,label}], createdAt} ] }]\n ============================================================ */\n(function () {\n 'use strict';\n\n const STORAGE_KEY = 'fs-io-chat';\n const PRICE_PER = 0.10;\n\n const MODELS = [\n { id: 'studio-v2', label: 'Airshelf v2', sub: '通用 · 速度优' },\n { id: 'studio-v2-pro', label: 'Airshelf v2 Pro', sub: '细节 · 商用' },\n { id: 'realistic', label: '写实增强', sub: '商品 · 人像' },\n { id: 'anime', label: '国风动漫', sub: '二次元 · 海报' },\n ];\n const RATIOS = [\n { id: '1:1', label: '1:1', sub: '通用方图' },\n { id: '16:9', label: '16:9', sub: '横屏 · 横幅' },\n { id: '9:16', label: '9:16', sub: '竖屏 · 短视频' },\n { id: '4:3', label: '4:3', sub: '横向标准' },\n { id: '3:4', label: '3:4', sub: '纵向标准' },\n ];\n const STYLES = [\n { id: 'auto', label: '默认' },\n { id: 'realistic', label: '写实' },\n { id: 'cinematic', label: '电影感' },\n { id: 'anime', label: '动漫' },\n { id: 'oil', label: '油画' },\n { id: 'cn-ink', label: '国风水墨' },\n { id: 'cyber', label: '赛博' },\n ];\n const COUNTS = [\n { id: 1, label: '1' },\n { id: 2, label: '2' },\n { id: 4, label: '4' },\n ];\n\n const EXAMPLES = [\n '一只穿着宇航服的橘猫,漂浮在霓虹色星云中,赛博朋克风',\n '极简北欧风格的茶杯,白底,自然柔光,产品摄影',\n '国风水墨海报,主体一只白鹤立于水边,留白构图',\n '电影感都市夜景,街道湿漉漉反射霓虹,4K 高清',\n ];\n\n /* ---------- DOM ---------- */\n const $ = sel => document.querySelector(sel);\n const streamInner = $('#io-stream-inner');\n const stream = $('#io-stream');\n const inputText = $('#io-input-text');\n const sendBtn = $('#io-send-btn');\n const addBtn = $('#io-add-btn');\n const fileInput = $('#io-file-input');\n const inputRefs = $('#io-input-refs');\n const costVal = $('#io-cost-val');\n const convList = $('#io-conv-list');\n const newConvBtn = $('#io-new-conv');\n const jumpBtn = $('#io-jump-bottom');\n const ioApp = $('.io-app');\n const foldBtn = $('.io-side-h .fold');\n const sideRestore = $('#io-side-restore');\n const toolbarSearchBtn = $('.io-toolbar .search-btn');\n const toolbarSearch = $('#io-toolbar-search');\n const toolbarSearchInput = $('#io-toolbar-search-input');\n const toolbarSearchClear = $('#io-toolbar-search-clear');\n\n function esc(s) { return String(s == null ? '' : s).replace(/[&<>\"']/g, c => ({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c])); }\n function uid() { return 'm-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5); }\n function relTime(ts) {\n const diff = (Date.now() - ts) / 1000;\n if (diff < 60) return '刚刚';\n if (diff < 3600) return Math.floor(diff / 60) + ' 分钟前';\n if (diff < 86400) return Math.floor(diff / 3600) + ' 小时前';\n if (diff < 86400 * 7) return Math.floor(diff / 86400) + ' 天前';\n const d = new Date(ts), z = n => (n < 10 ? '0' + n : '' + n);\n return d.getFullYear() + '-' + z(d.getMonth() + 1) + '-' + z(d.getDate());\n }\n\n /* ---------- state ---------- */\n const state = {\n convs: [], // 历史会话列表(元数据 id/title/thumb/updatedAt)\n activeConvId: 'default',\n messages: [], // 当前激活会话的对话流(从 convMessages 镜像出来)\n convMessages: { default: [] }, // 所有会话的 messages 按 convId 持久化\n refImages: [],\n toolbar: {\n q: '',\n time: 'all',\n mode: 'all',\n action: 'all',\n },\n param: {\n model: 'studio-v2',\n ratio: '1:1',\n style: 'auto',\n count: 4,\n },\n };\n\n const TOOL_FILTERS = {\n time: [\n { id: 'all', label: '时间' },\n { id: 'today', label: '今天' },\n { id: 'week', label: '近 7 天' },\n ],\n mode: [\n { id: 'all', label: '生成模式' },\n { id: 'studio-v2', label: 'Airshelf v2' },\n { id: 'studio-v2-pro', label: 'v2 Pro' },\n { id: 'realistic', label: '写实增强' },\n { id: 'anime', label: '国风动漫' },\n ],\n action: [\n { id: 'all', label: '操作类型' },\n { id: 'ok', label: '已完成' },\n { id: 'gen', label: '生成中' },\n { id: 'err', label: '失败' },\n ],\n };\n\n function slimMessages(msgs) {\n // dataUrl 体积大,持久化时剥离;只保留元数据\n return (msgs || []).map(m => ({\n ...m,\n refImages: (m.refImages || []).map(r => ({ id: r.id, name: r.name })),\n }));\n }\n\n function loadAll() {\n const fallback = { convs: [], activeConvId: 'default', convMessages: { default: [] } };\n try {\n const raw = localStorage.getItem(STORAGE_KEY);\n if (!raw) return fallback;\n const data = JSON.parse(raw) || {};\n // 兼容旧结构 { convs, current: { id, messages } } → 迁移到 convMessages\n if (!data.convMessages && data.current) {\n data.convMessages = {};\n data.convMessages[data.current.id || 'default'] = data.current.messages || [];\n data.activeConvId = data.current.id || 'default';\n delete data.current;\n }\n if (!data.convMessages) data.convMessages = { default: [] };\n if (!data.convMessages.default) data.convMessages.default = [];\n if (!Array.isArray(data.convs)) data.convs = [];\n if (!data.activeConvId) data.activeConvId = 'default';\n return data;\n } catch (e) { return fallback; }\n }\n\n function saveAll() {\n try {\n // 当前对话内容同步进字典\n state.convMessages[state.activeConvId] = slimMessages(state.messages);\n const data = {\n convs: state.convs.map(c => ({ id: c.id, title: c.title, thumb: c.thumb, updatedAt: c.updatedAt })),\n activeConvId: state.activeConvId,\n convMessages: state.convMessages,\n };\n localStorage.setItem(STORAGE_KEY, JSON.stringify(data));\n } catch (e) { /* quota */ }\n }\n\n /* ---------- 任务中心 · 同步图片创作任务到共享 localStorage ---------- */\n const IMG_TASK_KEY = 'fs-image-tasks-image';\n function _loadImgTasks() {\n try { return JSON.parse(localStorage.getItem(IMG_TASK_KEY) || '[]'); }\n catch (e) { return []; }\n }\n function _saveImgTasks(arr) {\n try { localStorage.setItem(IMG_TASK_KEY, JSON.stringify(arr)); } catch (e) {}\n }\n function _msgStatus(msg) {\n if (!msg.results || !msg.results.length) return 'gen';\n if (msg.results.some(r => r.status === 'gen')) return 'gen';\n if (msg.results.every(r => r.status === 'err')) return 'err';\n return 'ok';\n }\n function _timeNow(ts) {\n const d = new Date(ts || Date.now());\n return ('0'+(d.getMonth()+1)).slice(-2) + '.' + ('0'+d.getDate()).slice(-2)\n + ' ' + ('0'+d.getHours()).slice(-2) + ':' + ('0'+d.getMinutes()).slice(-2);\n }\n function syncImageTask(msg) {\n if (!msg || !msg.id) return;\n const arr = _loadImgTasks();\n const promptShort = (msg.prompt || '').trim().replace(/\\s+/g, ' ').slice(0, 24);\n const ratio = msg.ratio || '';\n const taskRec = {\n id: 'img-' + msg.id,\n type: 'image',\n name: '图片创作 · ' + (promptShort || '未命名 prompt'),\n snap: {\n prompt: msg.prompt,\n count: msg.count || (msg.results ? msg.results.length : 1),\n ratio: ratio, style: msg.style, model: msg.model,\n },\n status: _msgStatus(msg),\n time: _timeNow(msg.createdAt),\n createdAt: msg.createdAt || Date.now(),\n };\n const idx = arr.findIndex(t => t.id === taskRec.id);\n if (idx >= 0) arr[idx] = taskRec;\n else arr.unshift(taskRec);\n // 限制最多保留 200 条\n if (arr.length > 200) arr.length = 200;\n _saveImgTasks(arr);\n }\n\n /* ---------- 渲染:左侧会话列表 ---------- */\n function renderSide() {\n document.querySelectorAll('.io-conv-item.default').forEach(d =>\n d.classList.toggle('active', state.activeConvId === 'default'));\n if (!state.convs.length) {\n convList.innerHTML = `<div style=\"padding:14px 12px;font-size:11.5px;color:var(--black-alpha-48);line-height:1.55;\">\n 还没有最近会话<br><span style=\"font-family:var(--font-mono);font-size:10.5px;letter-spacing:.02em;display:inline-block;margin-top:4px\">// NO HISTORY</span>\n </div>`;\n return;\n }\n convList.innerHTML = state.convs.map(c => `\n <div class=\"io-conv-item${state.activeConvId === c.id ? ' active' : ''}\" data-id=\"${c.id}\">\n <div class=\"thumb\"${c.thumb ? ` style=\"background-image:url(${c.thumb})\"` : ''}>\n ${c.thumb ? '' : '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/><circle cx=\"8.5\" cy=\"8.5\" r=\"1.5\"/><path d=\"M21 15l-5-5-9 9\"/></svg>'}\n </div>\n <span class=\"nm\">${esc(c.title)}</span>\n <button class=\"del\" type=\"button\" data-del=\"${c.id}\" title=\"删除\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg>\n </button>\n </div>\n `).join('');\n convList.querySelectorAll('.io-conv-item').forEach(it => {\n it.addEventListener('click', e => {\n if (e.target.closest('[data-del]')) return;\n switchConv(it.dataset.id);\n });\n });\n convList.querySelectorAll('[data-del]').forEach(b => {\n b.addEventListener('click', e => {\n e.stopPropagation();\n deleteConv(b.dataset.del);\n });\n });\n }\n\n document.querySelectorAll('.io-conv-item.default').forEach(d => {\n d.addEventListener('click', () => switchConv('default'));\n });\n\n function switchConv(id) {\n if (id === state.activeConvId) return;\n // 先把当前会话内容写回字典(slim),再切换\n state.convMessages[state.activeConvId] = slimMessages(state.messages);\n state.activeConvId = id;\n // 从字典读出目标会话的 messages(没有就空)\n state.messages = (state.convMessages[id] || []).slice();\n saveAll();\n renderSide();\n renderStream();\n setTimeout(() => stream.scrollTo({ top: stream.scrollHeight }), 30);\n }\n\n function deleteConv(id) {\n state.convs = state.convs.filter(c => c.id !== id);\n delete state.convMessages[id];\n if (state.activeConvId === id) {\n state.activeConvId = 'default';\n state.messages = (state.convMessages['default'] || []).slice();\n }\n saveAll();\n renderSide();\n renderStream();\n }\n\n /* ---------- 渲染:中央对话流 ---------- */\n function getVisibleMessages() {\n const t = state.toolbar;\n const q = (t.q || '').trim().toLowerCase();\n const now = Date.now();\n return state.messages.filter(m => {\n if (q) {\n const hay = [\n m.prompt,\n m.ratio,\n modelLabel(m.model),\n styleLabel(m.style),\n ...(m.results || []).map(r => r.label || r.status),\n ].join(' ').toLowerCase();\n if (!hay.includes(q)) return false;\n }\n if (t.time === 'today' && now - (m.createdAt || now) > 86400000) return false;\n if (t.time === 'week' && now - (m.createdAt || now) > 86400000 * 7) return false;\n if (t.mode !== 'all' && m.model !== t.mode) return false;\n if (t.action !== 'all' && !(m.results || []).some(r => r.status === t.action)) return false;\n return true;\n });\n }\n\n function renderStream() {\n if (!state.messages.length) {\n streamInner.innerHTML = `\n <div class=\"io-empty\">\n <div class=\"ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z\"/><path d=\"M19 16l.85 2.3L22 19l-2.15.7L19 22l-.85-2.3L16 19l2.15-.7L19 16z\"/></svg>\n </div>\n <div class=\"badge\">// IMAGE STUDIO</div>\n <h2>开始你的创作</h2>\n <p>描述你想要的画面,AI 会按提示词 + 参考图 + 模型偏好,生成符合电商场景的视觉素材。<br>支持详情图、海报、灵感速写、3D 化等多场景。</p>\n <div class=\"examples\">\n ${EXAMPLES.map(e => `<button class=\"ex\" type=\"button\" data-ex=\"${esc(e)}\">${esc(e)}</button>`).join('')}\n </div>\n </div>`;\n // 示例点击\n streamInner.querySelectorAll('.ex').forEach(b => {\n b.addEventListener('click', () => {\n inputText.value = b.dataset.ex;\n syncSendDisabled();\n inputText.focus();\n autoResize();\n });\n });\n return;\n }\n const visible = getVisibleMessages();\n if (!visible.length) {\n streamInner.innerHTML = `\n <div class=\"io-empty\">\n <div class=\"ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/></svg>\n </div>\n <div class=\"badge\">// NO MATCH</div>\n <h2>没有符合筛选的结果</h2>\n <p>当前对话里没有匹配的批次,可以清空搜索或切回全部筛选。</p>\n <div class=\"examples\"><button class=\"ex\" type=\"button\" id=\"io-clear-toolbar-filters\">清空筛选</button></div>\n </div>`;\n $('#io-clear-toolbar-filters')?.addEventListener('click', () => {\n state.toolbar = { q: '', time: 'all', mode: 'all', action: 'all' };\n if (toolbarSearchInput) toolbarSearchInput.value = '';\n syncToolbarFilters();\n renderStream();\n });\n return;\n }\n streamInner.innerHTML = visible.map(messageHTML).join('');\n bindMessageEvents();\n }\n\n function rClass(r) { return 'r-' + r.replace(':', '-'); }\n function modelLabel(id) { const m = MODELS.find(x => x.id === id); return m ? m.label : id; }\n function styleLabel(id) { const s = STYLES.find(x => x.id === id); return s ? s.label : id; }\n\n function messageHTML(m) {\n const cellsHTML = m.results.map(r => `\n <div class=\"io-cell ${rClass(m.ratio)} ${r.status}\" data-msg-id=\"${m.id}\" data-cell-id=\"${r.id}\">\n <div class=\"ph-frame\">${r.status === 'gen' ? '生成中…' : (r.status === 'err' ? '失败 · 点重新生成' : esc(r.label))}</div>\n ${r.status === 'ok' ? `\n <div class=\"cell-ops\">\n <button type=\"button\" data-act=\"cell-rerun\" title=\"再次生成\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5\"/></svg>\n </button>\n <button type=\"button\" data-act=\"dl\" title=\"下载\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3\"/></svg>\n </button>\n <div class=\"cell-more-wrap\">\n <button type=\"button\" data-act=\"more\" title=\"更多\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"5\" cy=\"12\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"12\" cy=\"12\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"19\" cy=\"12\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/></svg>\n </button>\n <div class=\"cell-more-menu\">\n <button type=\"button\" data-act=\"save-lib\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z\"/></svg>加入资产库</button>\n <button type=\"button\" class=\"danger\" data-act=\"cell-del\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg>删除</button>\n </div>\n </div>\n </div>\n ` : ''}\n </div>\n `).join('');\n\n const tagsHTML = `\n <span class=\"meta-chip\">${esc(modelLabel(m.model))}</span>\n <span class=\"sep\">|</span>\n <span>${esc(m.ratio)}</span>\n <span class=\"sep\">|</span>\n <span>1K</span>\n ${m.style && m.style !== 'auto' ? `<span class=\"sep\">|</span><span>${esc(styleLabel(m.style))}</span>` : ''}\n <span class=\"sep\">·</span>\n <span>${esc(relTime(m.createdAt))}</span>\n `;\n\n return `<div class=\"io-msg\" data-id=\"${m.id}\">\n <div class=\"io-msg-prompt\">\n <div class=\"quote\">\n <svg viewBox=\"0 0 24 24\" fill=\"currentColor\" stroke=\"none\"><path d=\"M3 21V11a8 8 0 0 1 8-8h1v3h-1a5 5 0 0 0-5 5h6v10H3zm12 0V11a8 8 0 0 1 8-8h1v3h-1a5 5 0 0 0-5 5h6v10h-9z\"/></svg>\n </div>\n <div class=\"pt\">\n <div class=\"pt-text\">${esc(m.prompt)}</div>\n <div class=\"pt-tags\">${tagsHTML}</div>\n </div>\n </div>\n <div class=\"io-msg-grid\">${cellsHTML}</div>\n <div class=\"io-msg-ops\">\n <button type=\"button\" data-act=\"edit\" data-id=\"${m.id}\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 20h9\"/><path d=\"M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4z\"/></svg>\n 重新编辑\n </button>\n <button type=\"button\" data-act=\"rerun\" data-id=\"${m.id}\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5\"/></svg>\n 再次生成\n </button>\n <div class=\"msg-more-wrap\">\n <button type=\"button\" class=\"icon\" data-act=\"msg-more\" data-id=\"${m.id}\" title=\"更多\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"5\" cy=\"12\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"12\" cy=\"12\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"19\" cy=\"12\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/></svg>\n </button>\n <div class=\"msg-more-menu\" role=\"menu\">\n <button type=\"button\" data-act=\"msg-save-all\" data-id=\"${m.id}\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z\"/><polyline points=\"17 21 17 13 7 13 7 21\"/><polyline points=\"7 3 7 8 15 8\"/></svg>\n 全部加入资产库\n </button>\n <button type=\"button\" class=\"danger\" data-act=\"msg-del\" data-id=\"${m.id}\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg>\n 删除该批结果\n </button>\n </div>\n </div>\n </div>\n </div>`;\n }\n\n function bindMessageEvents() {\n streamInner.querySelectorAll('[data-act=\"edit\"]').forEach(b => {\n b.addEventListener('click', () => editMessage(b.dataset.id));\n });\n streamInner.querySelectorAll('[data-act=\"rerun\"]').forEach(b => {\n b.addEventListener('click', () => rerunMessage(b.dataset.id));\n });\n // 更多按钮 · 切换批次 menu\n streamInner.querySelectorAll('[data-act=\"msg-more\"]').forEach(b => {\n b.addEventListener('click', e => {\n e.stopPropagation();\n const wrap = b.closest('.msg-more-wrap');\n const isOpen = wrap.classList.contains('open');\n document.querySelectorAll('.msg-more-wrap.open').forEach(w => w.classList.remove('open'));\n if (!isOpen) wrap.classList.add('open');\n });\n });\n // 删除该批结果\n streamInner.querySelectorAll('[data-act=\"msg-del\"]').forEach(b => {\n b.addEventListener('click', e => {\n e.stopPropagation();\n const wrap = b.closest('.msg-more-wrap');\n if (wrap) wrap.classList.remove('open');\n state.messages = state.messages.filter(m => m.id !== b.dataset.id);\n saveAll(); renderStream();\n if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('已删除该批结果');\n });\n });\n // 全部加入资产库\n streamInner.querySelectorAll('[data-act=\"msg-save-all\"]').forEach(b => {\n b.addEventListener('click', e => {\n e.stopPropagation();\n const wrap = b.closest('.msg-more-wrap');\n if (wrap) wrap.classList.remove('open');\n saveBatchToLibrary(b.dataset.id);\n });\n });\n streamInner.querySelectorAll('[data-act=\"dl\"]').forEach(b => {\n b.addEventListener('click', e => {\n e.stopPropagation();\n if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('已加入下载', '占位 · mock 演示');\n });\n });\n // 更多 → 切换菜单\n streamInner.querySelectorAll('[data-act=\"more\"]').forEach(b => {\n b.addEventListener('click', e => {\n e.stopPropagation();\n const wrap = b.closest('.cell-more-wrap');\n const isOpen = wrap.classList.contains('open');\n // 关闭其它所有 menu\n document.querySelectorAll('.cell-more-wrap.open').forEach(w => w.classList.remove('open'));\n if (!isOpen) wrap.classList.add('open');\n });\n });\n // 加入资产库\n streamInner.querySelectorAll('[data-act=\"save-lib\"]').forEach(b => {\n b.addEventListener('click', e => {\n e.stopPropagation();\n const cell = b.closest('.io-cell');\n const wrap = b.closest('.cell-more-wrap');\n if (wrap) wrap.classList.remove('open');\n saveToLibrary(cell.dataset.msgId, cell.dataset.cellId);\n });\n });\n // 单张删除\n streamInner.querySelectorAll('[data-act=\"cell-del\"]').forEach(b => {\n b.addEventListener('click', e => {\n e.stopPropagation();\n const cell = b.closest('.io-cell');\n const wrap = b.closest('.cell-more-wrap');\n if (wrap) wrap.classList.remove('open');\n deleteCell(cell.dataset.msgId, cell.dataset.cellId);\n });\n });\n // 单张再次生成\n streamInner.querySelectorAll('[data-act=\"cell-rerun\"]').forEach(b => {\n b.addEventListener('click', e => {\n e.stopPropagation();\n const cell = b.closest('.io-cell');\n rerunCell(cell.dataset.msgId, cell.dataset.cellId);\n });\n });\n }\n\n /* ---------- 单张再次生成 ---------- */\n function rerunCell(msgId, cellId) {\n const m = state.messages.find(x => x.id === msgId);\n if (!m) return;\n const r = (m.results || []).find(x => x.id === cellId);\n if (!r) return;\n r.status = 'gen';\n saveAll();\n renderStream();\n setTimeout(() => {\n r.status = Math.random() < 0.06 ? 'err' : 'ok';\n saveAll();\n renderStream();\n syncImageTask(m);\n }, 1100 + Math.random() * 800);\n if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('已重跑', '该图重新生成中');\n }\n\n /* ---------- 单张删除 / 加入资产库 ---------- */\n function deleteCell(msgId, cellId) {\n const m = state.messages.find(x => x.id === msgId);\n if (!m) return;\n m.results = (m.results || []).filter(r => r.id !== cellId);\n // 如果该 msg 下没有 result 了,顺手删掉整条\n if (!m.results.length) state.messages = state.messages.filter(x => x.id !== msgId);\n saveAll();\n renderStream();\n if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('已删除');\n }\n\n /* ---------- 资产库 IO ---------- */\n const LIB_KEY = 'fs-library-unclassified';\n function readLib() {\n let list;\n try { list = JSON.parse(localStorage.getItem(LIB_KEY) || '[]'); } catch (e) { list = []; }\n return Array.isArray(list) ? list : [];\n }\n function writeLib(list) {\n try { localStorage.setItem(LIB_KEY, JSON.stringify(list)); } catch (e) {}\n }\n function buildLibEntry(m, cellId) {\n return {\n id: 'lib-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5),\n cellId: cellId,\n name: (m.prompt || '未命名').slice(0, 18),\n prompt: m.prompt || '',\n ratio: m.ratio,\n model: m.model,\n style: m.style,\n source: '图片创作',\n kind: '未分类',\n addedAt: Date.now(),\n };\n }\n\n /* ---------- 重复确认弹窗 ---------- */\n function confirmDup({ count, isBatch }) {\n return new Promise(resolve => {\n const bg = document.getElementById('io-dup-bg');\n document.getElementById('io-dup-title').textContent = isBatch\n ? `${count} 张已在资产库`\n : '该图已在资产库';\n document.getElementById('io-dup-sub').textContent = isBatch\n ? '// 新增副本 = 各自独立 · 覆盖 = 更新时间到顶'\n : '// 新增副本 = 多一份独立条目 · 覆盖 = 更新时间到顶';\n bg.hidden = false;\n const buttons = bg.querySelectorAll('button[data-act]');\n function done(choice) {\n bg.hidden = true;\n buttons.forEach(b => b.onclick = null);\n bg.onclick = null;\n document.removeEventListener('keydown', escHandler);\n resolve(choice);\n }\n function escHandler(e) { if (e.key === 'Escape') done('cancel'); }\n buttons.forEach(b => b.onclick = () => done(b.dataset.act));\n bg.onclick = e => { if (e.target === bg) done('cancel'); };\n document.addEventListener('keydown', escHandler);\n });\n }\n\n /* ---------- 单张加入资产库 ---------- */\n async function saveToLibrary(msgId, cellId) {\n const m = state.messages.find(x => x.id === msgId);\n if (!m) return;\n const r = (m.results || []).find(x => x.id === cellId);\n if (!r) return;\n const list = readLib();\n const dupIdx = list.findIndex(x => x.cellId === cellId);\n if (dupIdx >= 0) {\n const choice = await confirmDup({ count: 1, isBatch: false });\n if (choice === 'cancel') return;\n if (choice === 'overwrite') {\n // 移除旧的,把新的放到最前\n const [old] = list.splice(dupIdx, 1);\n list.unshift({ ...old, addedAt: Date.now(), prompt: m.prompt || old.prompt, ratio: m.ratio, model: m.model, style: m.style });\n writeLib(list);\n if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('已覆盖原图', '资产库 → 未分类');\n return;\n }\n // dup: 新增副本 — 不去重,继续走 unshift\n }\n list.unshift(buildLibEntry(m, cellId));\n writeLib(list);\n if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('已加入资产库', '+ 1 张 · 资产库 → 未分类');\n }\n\n /* ---------- 整批加入资产库 ---------- */\n async function saveBatchToLibrary(msgId) {\n const m = state.messages.find(x => x.id === msgId);\n if (!m || !m.results || !m.results.length) {\n if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('无可加入的结果', '该批次为空');\n return;\n }\n const list = readLib();\n const existing = new Set(list.map(x => x.cellId));\n const dupCells = m.results.filter(r => existing.has(r.id));\n const newCells = m.results.filter(r => !existing.has(r.id));\n\n let dupAction = 'skip'; // 默认仅加入新的\n if (dupCells.length > 0) {\n const choice = await confirmDup({ count: dupCells.length, isBatch: true });\n if (choice === 'cancel') return;\n dupAction = choice; // 'dup' = 新增副本 / 'overwrite' = 覆盖\n }\n\n let added = 0, overwritten = 0;\n // 先处理新增的 cell\n newCells.forEach(r => {\n list.unshift(buildLibEntry(m, r.id));\n added++;\n });\n // 处理重复的\n if (dupAction === 'dup') {\n dupCells.forEach(r => { list.unshift(buildLibEntry(m, r.id)); added++; });\n } else if (dupAction === 'overwrite') {\n dupCells.forEach(r => {\n const idx = list.findIndex(x => x.cellId === r.id);\n if (idx >= 0) {\n const [old] = list.splice(idx, 1);\n list.unshift({ ...old, addedAt: Date.now(), prompt: m.prompt || old.prompt, ratio: m.ratio, model: m.model, style: m.style });\n overwritten++;\n }\n });\n }\n writeLib(list);\n if (typeof Shell !== 'undefined' && Shell.toast) {\n const parts = [];\n if (added > 0) parts.push(`+ ${added} 张`);\n if (overwritten > 0) parts.push(`覆盖 ${overwritten} 张`);\n Shell.toast(added > 0 || overwritten > 0 ? '已加入资产库' : '已取消', parts.length ? parts.join(' · ') + ' · 资产库 → 未分类' : '无变更');\n }\n }\n\n function editMessage(id) {\n const m = state.messages.find(x => x.id === id);\n if (!m) return;\n inputText.value = m.prompt;\n state.param = { model: m.model, ratio: m.ratio, style: m.style, count: m.count };\n syncParamLabels();\n syncSendDisabled();\n autoResize();\n inputText.focus();\n stream.scrollTo({ top: stream.scrollHeight, behavior: 'smooth' });\n }\n\n function rerunMessage(id) {\n const m = state.messages.find(x => x.id === id);\n if (!m) return;\n const newMsg = createMessage(m.prompt, { model: m.model, ratio: m.ratio, style: m.style, count: m.count, refImages: m.refImages || [] });\n state.messages.push(newMsg);\n saveAll();\n renderStream();\n scheduleResults(newMsg);\n setTimeout(() => stream.scrollTo({ top: stream.scrollHeight, behavior: 'smooth' }), 30);\n }\n\n /* ---------- 生成 ---------- */\n function createMessage(prompt, opts) {\n const p = opts || {};\n const count = p.count || state.param.count;\n return {\n id: uid(),\n prompt: prompt,\n model: p.model || state.param.model,\n ratio: p.ratio || state.param.ratio,\n style: p.style || state.param.style,\n count: count,\n refImages: p.refImages || state.refImages.slice(),\n results: Array.from({ length: count }, (_, i) => ({\n id: 'r-' + uid(), status: 'gen', label: (p.ratio || state.param.ratio) + ' · #' + (i + 1),\n })),\n createdAt: Date.now(),\n };\n }\n function scheduleResults(msg) {\n msg.results.forEach((r, idx) => {\n setTimeout(() => {\n r.status = Math.random() < 0.06 ? 'err' : 'ok';\n saveAll();\n renderStream();\n syncImageTask(msg);\n }, 1100 + idx * 300 + Math.random() * 600);\n });\n }\n\n function send() {\n const txt = (inputText.value || '').trim();\n if (!txt) return;\n const msg = createMessage(txt);\n state.messages.push(msg);\n inputText.value = '';\n state.refImages = [];\n renderRefs();\n syncSendDisabled();\n autoResize();\n saveAll();\n renderStream();\n scheduleResults(msg);\n syncImageTask(msg);\n setTimeout(() => stream.scrollTo({ top: stream.scrollHeight, behavior: 'smooth' }), 30);\n }\n\n /* ---------- 输入栏:参考图 ---------- */\n function renderRefs() {\n if (!state.refImages.length) {\n inputRefs.classList.remove('show');\n inputRefs.innerHTML = '';\n return;\n }\n inputRefs.classList.add('show');\n inputRefs.innerHTML = state.refImages.map(r => `\n <div class=\"io-input-ref\" data-id=\"${r.id}\">\n <img src=\"${r.dataUrl}\" alt=\"\">\n <button class=\"x\" type=\"button\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"><path d=\"M4 4l8 8M12 4l-8 8\"/></svg></button>\n </div>\n `).join('');\n inputRefs.querySelectorAll('.x').forEach(b => {\n b.onclick = e => {\n e.stopPropagation();\n const id = b.closest('.io-input-ref').dataset.id;\n state.refImages = state.refImages.filter(r => r.id !== id);\n renderRefs();\n };\n });\n }\n\n addBtn.addEventListener('click', () => fileInput.click());\n fileInput.addEventListener('change', e => {\n const fs = [...e.target.files].filter(f => f.type.startsWith('image/'));\n const room = 3 - state.refImages.length;\n if (room <= 0) { e.target.value = ''; return; }\n const incoming = fs.slice(0, room);\n let done = 0;\n incoming.forEach(f => {\n const reader = new FileReader();\n reader.onload = ev => {\n state.refImages.push({ id: 'rf-' + uid(), dataUrl: ev.target.result, name: f.name });\n if (++done === incoming.length) renderRefs();\n };\n reader.readAsDataURL(f);\n });\n e.target.value = '';\n });\n\n /* ---------- 输入框自适应高度 ---------- */\n function autoResize() {\n inputText.style.height = 'auto';\n inputText.style.height = Math.min(180, inputText.scrollHeight) + 'px';\n }\n inputText.addEventListener('input', () => {\n syncSendDisabled();\n autoResize();\n });\n function syncSendDisabled() {\n sendBtn.disabled = !(inputText.value || '').trim();\n }\n\n // 回车发送(Shift+Enter 换行)\n inputText.addEventListener('keydown', e => {\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n if (!sendBtn.disabled) send();\n }\n });\n sendBtn.addEventListener('click', send);\n\n /* ---------- 参数胶囊下拉 ---------- */\n function buildParamMenus() {\n function fill(menuEl, items, key, getLabel) {\n menuEl.innerHTML = items.map(it => {\n const v = it.id;\n return `<div class=\"mi${state.param[key] === v ? ' selected' : ''}\" data-val=\"${v}\">\n <svg class=\"mi-check\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg>\n <span>${esc(it.label)}</span>\n ${it.sub ? `<span class=\"mi-sub\">${esc(it.sub)}</span>` : ''}\n </div>`;\n }).join('');\n menuEl.addEventListener('click', e => {\n const mi = e.target.closest('.mi');\n if (!mi) return;\n e.stopPropagation();\n const val = key === 'count' ? parseInt(mi.dataset.val, 10) : mi.dataset.val;\n state.param[key] = val;\n menuEl.parentElement.classList.remove('open');\n syncParamLabels();\n updateCost();\n // 重新渲染当前菜单的 selected 状态\n menuEl.querySelectorAll('.mi').forEach(x => x.classList.toggle('selected', x.dataset.val == String(val)));\n });\n }\n fill($('#io-menu-model'), MODELS, 'model');\n fill($('#io-menu-ratio'), RATIOS, 'ratio');\n fill($('#io-menu-style'), STYLES, 'style');\n fill($('#io-menu-count'), COUNTS, 'count');\n }\n\n function syncParamLabels() {\n $('#io-param-model-lbl').textContent = (MODELS.find(m => m.id === state.param.model) || {}).label || state.param.model;\n $('#io-param-ratio-lbl').textContent = state.param.ratio;\n $('#io-param-style-lbl').textContent = (STYLES.find(s => s.id === state.param.style) || {}).label || state.param.style;\n $('#io-param-count-lbl').textContent = state.param.count;\n }\n function updateCost() {\n costVal.textContent = '¥' + (state.param.count * PRICE_PER).toFixed(2);\n }\n\n // 点击 chip 切换下拉\n document.querySelectorAll('.io-input-bottom .param').forEach(p => {\n p.addEventListener('click', e => {\n // 菜单内 click 已 stop,这里只处理 chip 本体\n if (e.target.closest('.io-param-menu')) return;\n const isOpen = p.classList.contains('open');\n document.querySelectorAll('.io-input-bottom .param.open').forEach(x => x.classList.remove('open'));\n if (!isOpen) p.classList.add('open');\n });\n });\n document.addEventListener('click', e => {\n if (!e.target.closest('.io-input-bottom .param')) {\n document.querySelectorAll('.io-input-bottom .param.open').forEach(x => x.classList.remove('open'));\n }\n if (!e.target.closest('.io-tool-filter')) {\n document.querySelectorAll('.io-tool-filter.open').forEach(x => x.classList.remove('open'));\n }\n if (!e.target.closest('.cell-more-wrap')) {\n document.querySelectorAll('.cell-more-wrap.open').forEach(x => x.classList.remove('open'));\n }\n if (!e.target.closest('.msg-more-wrap')) {\n document.querySelectorAll('.msg-more-wrap.open').forEach(x => x.classList.remove('open'));\n }\n });\n\n /* ---------- 顶部工具栏:折叠 / 搜索 / 筛选 ---------- */\n function setSideCollapsed(collapsed) {\n ioApp?.classList.toggle('side-collapsed', collapsed);\n if (sideRestore) sideRestore.hidden = !collapsed;\n try { localStorage.setItem('fs-io-side-collapsed', collapsed ? '1' : '0'); } catch (e) {}\n }\n foldBtn?.addEventListener('click', () => setSideCollapsed(true));\n sideRestore?.addEventListener('click', () => setSideCollapsed(false));\n\n toolbarSearchBtn?.addEventListener('click', () => {\n const open = toolbarSearch?.hidden;\n if (toolbarSearch) toolbarSearch.hidden = !open;\n toolbarSearchBtn.classList.toggle('active', !!open || !!state.toolbar.q);\n if (open) requestAnimationFrame(() => toolbarSearchInput?.focus());\n });\n toolbarSearchInput?.addEventListener('input', e => {\n state.toolbar.q = e.target.value.trim();\n toolbarSearchBtn?.classList.toggle('active', !!state.toolbar.q || !toolbarSearch?.hidden);\n renderStream();\n });\n toolbarSearchClear?.addEventListener('click', () => {\n state.toolbar.q = '';\n if (toolbarSearchInput) toolbarSearchInput.value = '';\n toolbarSearchBtn?.classList.toggle('active', !toolbarSearch?.hidden);\n renderStream();\n toolbarSearchInput?.focus();\n });\n\n function syncToolbarFilters() {\n document.querySelectorAll('.io-tool-filter').forEach(wrap => {\n const key = wrap.dataset.filter;\n const value = state.toolbar[key] || 'all';\n const items = TOOL_FILTERS[key] || [];\n const selected = items.find(x => x.id === value) || items[0];\n wrap.querySelector('[data-filter-label]').textContent = selected?.label || '';\n wrap.querySelector('.tb-chip')?.classList.toggle('active', value !== 'all');\n const menu = wrap.querySelector('[data-filter-menu]');\n if (!menu) return;\n menu.innerHTML = items.map(it => `\n <button class=\"mi${it.id === value ? ' selected' : ''}\" type=\"button\" data-val=\"${esc(it.id)}\">\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg>\n <span>${esc(it.label)}</span>\n </button>\n `).join('');\n });\n }\n\n document.querySelectorAll('.io-tool-filter').forEach(wrap => {\n const btn = wrap.querySelector('.tb-chip');\n const menu = wrap.querySelector('[data-filter-menu]');\n btn?.addEventListener('click', e => {\n e.stopPropagation();\n const open = wrap.classList.contains('open');\n document.querySelectorAll('.io-tool-filter.open').forEach(x => x.classList.remove('open'));\n if (!open) wrap.classList.add('open');\n });\n menu?.addEventListener('click', e => {\n const item = e.target.closest('.mi');\n if (!item) return;\n e.stopPropagation();\n state.toolbar[wrap.dataset.filter] = item.dataset.val;\n wrap.classList.remove('open');\n syncToolbarFilters();\n renderStream();\n });\n });\n\n /* ---------- 新对话 ---------- */\n newConvBtn.addEventListener('click', () => {\n // 若 default 上有内容,把它归档到「最近」(新 id + 转存 messages),然后清空 default\n if (state.activeConvId === 'default' && state.messages.length) {\n const first = state.messages[0];\n const title = (first.prompt || '').slice(0, 18) || '未命名对话';\n const newId = 'c-' + uid();\n state.convMessages[newId] = slimMessages(state.messages);\n state.convs.unshift({\n id: newId,\n title,\n thumb: '',\n updatedAt: Date.now(),\n });\n // 限制 20 条 + 同步清掉超额会话的 messages\n if (state.convs.length > 20) {\n const dropped = state.convs.slice(20);\n state.convs = state.convs.slice(0, 20);\n dropped.forEach(c => { delete state.convMessages[c.id]; });\n }\n }\n // 清空 default 并切回 default\n state.convMessages['default'] = [];\n state.messages = [];\n state.activeConvId = 'default';\n saveAll();\n renderSide();\n renderStream();\n inputText.focus();\n });\n\n /* ---------- 浮动\"回到底部\" ---------- */\n stream.addEventListener('scroll', () => {\n const near = stream.scrollTop + stream.clientHeight >= stream.scrollHeight - 120;\n jumpBtn.classList.toggle('show', !near && state.messages.length > 0);\n });\n jumpBtn.addEventListener('click', () => {\n stream.scrollTo({ top: stream.scrollHeight, behavior: 'smooth' });\n });\n\n /* ---------- 初始化 ---------- */\n (function init() {\n const data = loadAll();\n state.convs = data.convs || [];\n state.convMessages = data.convMessages || { default: [] };\n state.activeConvId = data.activeConvId || 'default';\n state.messages = (state.convMessages[state.activeConvId] || []).slice();\n // URL ?prompt= 预填\n try {\n const q = new URLSearchParams(location.search);\n const seed = q.get('prompt');\n if (seed) inputText.value = decodeURIComponent(seed);\n } catch (e) {}\n buildParamMenus();\n syncParamLabels();\n syncToolbarFilters();\n try { setSideCollapsed(localStorage.getItem('fs-io-side-collapsed') === '1'); } catch (e) {}\n updateCost();\n renderRefs();\n syncSendDisabled();\n autoResize();\n renderSide();\n renderStream();\n setTimeout(() => stream.scrollTo({ top: stream.scrollHeight }), 50);\n })();\n})();\n</script>\n<script src=\"/exact/assets/api-bridge.js?v=2026060114\"></script>\n</body>\n</html>\n",
|
||
"library": "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta name=\"x-airshelf-exact-source\" content=\"library.html\">\n<meta charset=\"utf-8\">\n<title>资产库 · Airshelf</title>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"/exact/assets/restraint.css?v=2026060101\">\n<style>\n .asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; }\n .asset-grid.video-grid { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }\n /* 修复:.asset-grid 的 display:grid 会盖过 [hidden] 的默认 display:none, 导致切 tab 时其它 tab 的卡片仍可见 */\n .asset-grid[hidden] { display: none; }\n\n /* ─── 底部分页 (吸底) ─── */\n .pagination {\n position: sticky;\n bottom: 0;\n z-index: 5;\n display: flex; align-items: center; gap: 16px;\n padding: 14px 28px;\n margin: 20px -28px 0;\n border-top: 1px solid var(--border-faint);\n background: var(--background-base);\n box-shadow: 0 -8px 24px -16px rgba(0, 0, 0, .08);\n font-size: 12.5px;\n color: var(--black-alpha-56);\n }\n .pagination[hidden] { display: none; }\n .pagination .total { font-family: var(--font-mono); letter-spacing: .02em; }\n .pagination .page-size {\n display: inline-flex; align-items: center; gap: 4px;\n height: 30px; padding: 0 10px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n cursor: pointer;\n font-family: inherit; font-size: 12.5px;\n color: var(--black-alpha-72);\n transition: border-color var(--t-base), color var(--t-base);\n }\n .pagination .page-size:hover { border-color: var(--black-alpha-32); color: var(--accent-black); }\n .pagination .page-size svg { width: 10px; height: 10px; opacity: .6; }\n .pagination .pages {\n display: inline-flex; gap: 4px;\n margin-left: auto;\n }\n .pagination .pages button {\n min-width: 30px; height: 30px;\n padding: 0 8px;\n border: 1px solid var(--border-faint);\n background: var(--surface);\n border-radius: var(--r-sm);\n cursor: pointer;\n font-size: 12.5px;\n color: var(--black-alpha-72);\n font-family: inherit;\n transition: background var(--t-base), border-color var(--t-base), color var(--t-base);\n }\n .pagination .pages button:hover:not(.active):not(:disabled) { border-color: var(--black-alpha-32); color: var(--accent-black); }\n .pagination .pages button.active {\n background: var(--heat);\n color: var(--accent-white);\n border-color: var(--heat);\n font-weight: 600;\n }\n .pagination .pages button:disabled { opacity: .4; cursor: not-allowed; }\n .pagination .pages .ellipsis {\n min-width: 22px; height: 30px;\n display: inline-flex; align-items: center; justify-content: center;\n color: var(--black-alpha-48);\n font-family: var(--font-mono);\n }\n .pagination .jump {\n display: inline-flex; align-items: center; gap: 6px;\n color: var(--black-alpha-56);\n }\n .pagination .jump input {\n width: 44px; height: 30px;\n border: 1px solid var(--border-faint);\n background: var(--surface);\n border-radius: var(--r-sm);\n text-align: center;\n font-size: 12.5px;\n color: var(--accent-black);\n font-family: inherit;\n outline: none;\n transition: border-color var(--t-base);\n }\n .pagination .jump input:focus { border-color: var(--heat-40); }\n @media (max-width: 1100px) {\n .pagination { margin: 20px -24px 0; padding: 14px 24px; }\n }\n .asset-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background .15s; position: relative; }\n .asset-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }\n /* 下载按钮 · hover 卡片显示,与 card-del-btn 并列 · PRD §6.5 中间产物可下载 */\n .asset-card .card-dl-btn {\n position: absolute;\n top: 8px; right: 48px;\n width: 32px; height: 32px;\n background: rgba(255,255,255,.95);\n border: 1px solid var(--black-alpha-12);\n border-radius: var(--r-md);\n display: grid; place-items: center;\n color: var(--black-alpha-56);\n cursor: pointer;\n opacity: 0;\n transition: opacity var(--t-base), background var(--t-base), color var(--t-base);\n z-index: 4;\n }\n .asset-card .card-dl-btn svg { width: 14px; height: 14px; }\n .asset-card:hover .card-dl-btn { opacity: 1; }\n .asset-card .card-dl-btn:hover { background: var(--heat); color: var(--accent-white); border-color: var(--heat); }\n body.edit-mode .asset-card .card-dl-btn { opacity: 0 !important; pointer-events: none !important; }\n /* 编辑模式 checkbox */\n .asset-card .card-check {\n position: absolute; top: 10px; left: 10px;\n width: 22px; height: 22px;\n border-radius: 50%;\n background: var(--surface);\n border: 2px solid var(--black-alpha-32);\n display: none;\n place-items: center;\n color: var(--accent-white);\n z-index: 5;\n pointer-events: none;\n }\n .asset-card .card-check svg { width: 11px; height: 11px; opacity: 0; }\n body.edit-mode .asset-card { cursor: pointer; }\n body.edit-mode .asset-card .card-check { display: grid; }\n body.edit-mode .asset-card.selected .card-check {\n background: var(--heat); border-color: var(--heat);\n }\n body.edit-mode .asset-card.selected .card-check svg { opacity: 1; }\n body.edit-mode .asset-card.selected { border-color: var(--heat); box-shadow: 0 0 0 1px var(--heat) inset; }\n body.edit-mode .asset-card .card-del-btn { opacity: 0 !important; pointer-events: none !important; }\n /* edit-mode 下「管理资产」按钮变成「完成」 */\n .btn.active {\n background: var(--accent-black);\n color: var(--accent-white);\n border-color: var(--accent-black);\n }\n /* bulk-bar (浮动批量操作栏) */\n .bulk-bar {\n position: fixed;\n bottom: 24px; left: 50%;\n transform: translateX(-50%);\n background: var(--accent-black);\n color: var(--accent-white);\n border-radius: var(--r-md);\n padding: 10px 14px 10px 18px;\n display: none;\n align-items: center; gap: 16px;\n box-shadow: 0 8px 24px rgba(0,0,0,.18);\n z-index: 100;\n font-size: 13px;\n }\n body.edit-mode .bulk-bar { display: inline-flex; }\n .bulk-bar .ct { font-family: var(--font-mono); letter-spacing: .02em; }\n .bulk-bar .ct b { color: var(--heat); font-weight: 700; padding: 0 3px; }\n .bulk-bar .sep { width: 1px; height: 18px; background: rgba(255,255,255,.16); }\n .bulk-bar button {\n height: 30px; padding: 0 12px;\n background: transparent;\n border: 1px solid rgba(255,255,255,.24);\n border-radius: var(--r-sm);\n color: var(--accent-white);\n font-size: 12.5px;\n font-family: inherit;\n cursor: pointer;\n display: inline-flex; align-items: center; gap: 5px;\n transition: background var(--t-base);\n }\n .bulk-bar button:hover { background: rgba(255,255,255,.08); }\n .bulk-bar button.danger { background: var(--accent-crimson); border-color: var(--accent-crimson); }\n .bulk-bar button.danger:hover { filter: brightness(1.06); }\n .bulk-bar button svg { width: 12px; height: 12px; }\n .bulk-bar .clear-sel { color: rgba(255,255,255,.6); font-size: 12px; cursor: pointer; background: none; border: 0; padding: 4px 6px; }\n .bulk-bar .clear-sel:hover { color: var(--accent-white); }\n /* 移动到 · 弹层菜单 (向上弹) */\n .bulk-bar .move-wrap { position: relative; display: inline-flex; }\n .bulk-bar .move-menu {\n position: absolute;\n bottom: calc(100% + 8px);\n right: 0;\n min-width: 160px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n padding: 6px;\n box-shadow: 0 8px 24px rgba(0,0,0,.18);\n display: none;\n z-index: 2;\n }\n .bulk-bar .move-menu.show { display: block; }\n .bulk-bar .move-menu .mv-item {\n display: flex; align-items: center; gap: 8px;\n width: 100%; height: 32px; padding: 0 10px;\n background: transparent; border: 0;\n color: var(--accent-black);\n font-size: 13px; font-family: inherit;\n cursor: pointer; border-radius: var(--r-sm);\n text-align: left;\n }\n .bulk-bar .move-menu .mv-item:hover { background: var(--heat-12); color: var(--heat); }\n .bulk-bar .move-menu .mv-item svg { width: 12px; height: 12px; opacity: .7; }\n /* tab 作为拖拽目标 hover 态 */\n .tabs .tab.drag-over {\n background: var(--heat-12);\n color: var(--heat);\n border-radius: var(--r-sm);\n box-shadow: inset 0 0 0 1px var(--heat-40);\n }\n body.edit-mode .asset-card { cursor: grab; }\n body.edit-mode .asset-card.dragging { opacity: .4; }\n .asset-thumb { aspect-ratio: 1; }\n .asset-card.video .asset-thumb { aspect-ratio: 9/16; max-height: 280px; }\n .asset-body { padding: 12px 14px; }\n .asset-name { font-size: 13px; font-weight: 600; color: var(--accent-black); }\n .asset-meta { font-size: 11px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }\n .asset-badge { position: absolute; top: 8px; left: 8px; font-family: var(--font-mono); font-size: 10px; letter-spacing: .04em; padding: 2px 6px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-56); }\n .asset-card .placeholder { position: relative; }\n\n /* ─── Upload modal ─── */\n .upload-modal {\n max-width: 520px; width: 92%;\n max-height: calc(100vh - 80px);\n display: flex; flex-direction: column;\n }\n .upload-modal .modal-h {\n position: relative;\n flex-shrink: 0;\n }\n .upload-modal .modal-h .ti { flex: 1; min-width: 0; }\n .upload-modal .modal-h .modal-x {\n width: 32px; height: 32px; border-radius: var(--r-md);\n display: grid; place-items: center;\n color: var(--black-alpha-56); cursor: pointer;\n background: transparent; border: 0; padding: 0;\n flex-shrink: 0;\n transition: background var(--t-base), color var(--t-base);\n }\n .upload-modal .modal-h .modal-x:hover { background: var(--black-alpha-4); color: var(--accent-crimson); }\n .upload-modal .modal-h .modal-x svg { width: 14px; height: 14px; }\n .upload-modal .modal-b {\n flex: 1; min-height: 0;\n overflow-y: auto;\n padding: 20px 24px;\n }\n .upload-modal .modal-f { flex-shrink: 0; }\n .upload-modal .field { margin-bottom: 16px; }\n .upload-modal .field:last-child { margin-bottom: 0; }\n /* 修复:.field 的 display:flex 会盖过 [hidden] 的默认 display:none */\n .upload-modal .modal-b .field[hidden] { display: none; }\n .upload-modal .upload-zone {\n display: flex; flex-direction: column; align-items: center; justify-content: center;\n gap: 8px; padding: 24px; cursor: pointer;\n border: 1px dashed var(--border-faint); border-radius: var(--r-md);\n background: var(--background-lighter); color: var(--black-alpha-56);\n font-size: 13px; transition: border-color .15s, background .15s, color .15s;\n }\n .upload-modal .upload-zone:hover { border-color: var(--heat); background: var(--heat-8); color: var(--heat); }\n .upload-modal .upload-zone:hover .uz-ic { background: var(--heat); color: #fff; border-color: var(--heat); }\n .upload-modal .upload-zone .uz-ic {\n width: 40px; height: 40px; border-radius: var(--r-md);\n border: 1px solid var(--border-faint); background: var(--surface);\n display: grid; place-items: center; color: var(--black-alpha-56);\n transition: background .15s, color .15s, border-color .15s;\n }\n .upload-modal .upload-zone .uz-ic svg { width: 18px; height: 18px; }\n .upload-modal .upload-zone .uz-hint { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .upload-modal .upload-preview {\n position: relative;\n width: calc((100% - 32px) / 5);\n min-width: 80px; max-width: 110px;\n aspect-ratio: 1; border-radius: var(--r-md);\n overflow: hidden; border: 1px solid var(--border-faint); background: var(--background-lighter);\n }\n .upload-modal .upload-preview.video { aspect-ratio: 9/16; max-height: none; min-width: 80px; max-width: 110px; margin: 0; }\n .upload-modal .upload-preview img,\n .upload-modal .upload-preview video { width: 100%; height: 100%; object-fit: cover; display: block; }\n .upload-modal .upload-preview .preview-x {\n position: absolute; top: 8px; right: 8px;\n width: 24px; height: 24px; border-radius: 999px;\n background: rgba(21, 20, 15, .7); color: #fff;\n display: grid; place-items: center; cursor: pointer; border: 0;\n transition: background .15s, transform .15s;\n }\n .upload-modal .upload-preview .preview-x:hover { background: var(--accent-crimson); transform: scale(1.08); }\n .upload-modal .upload-preview .preview-x svg { width: 12px; height: 12px; }\n .upload-modal .modal-f { align-items: center; }\n .upload-modal .modal-f .modal-meta {\n flex: 1; font-family: var(--font-mono); font-size: 11.5px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n }\n .upload-modal .modal-f .modal-meta .accent { color: var(--heat); font-weight: 600; }\n .upload-modal .btn-primary:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; }\n</style>\n</head>\n<body>\n<div id=\"page\">\n\n<div class=\"page-head\">\n <div>\n <h1>资产库</h1>\n <div class=\"sub\"><span class=\"mono\">// 跨项目复用 · <span id=\"sub-people\">0</span> 人 · <span id=\"sub-scenes\">0</span> 景 · <span id=\"sub-products\">0</span> 商 · <span id=\"sub-finals\">0</span> 片</span></div>\n </div>\n <div class=\"actions\">\n <button class=\"btn\" type=\"button\" id=\"lib-manage-btn\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m3 7 2 2 4-4\"/><path d=\"m3 17 2 2 4-4\"/><path d=\"M13 6h8\"/><path d=\"M13 12h8\"/><path d=\"M13 18h8\"/></svg>\n <span class=\"lib-manage-label\">管理资产</span>\n </button>\n <button class=\"btn btn-primary\" id=\"open-upload-btn\" type=\"button\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12\"/></svg>\n 上传资产\n </button>\n </div>\n</div>\n\n<div class=\"tabs\" id=\"asset-tabs\">\n <div class=\"tab active\" data-tab=\"people\">人物 <span class=\"count\">0</span></div>\n <div class=\"tab\" data-tab=\"scenes\">场景 <span class=\"count\">0</span></div>\n <div class=\"tab\" data-tab=\"products\">商品图 <span class=\"count\">0</span></div>\n <div class=\"tab\" data-tab=\"finals\">成片 <span class=\"count\">0</span></div>\n <div class=\"tab\" data-tab=\"uploads\">我的上传 <span class=\"count\">0</span></div>\n <div class=\"tab\" data-tab=\"unclassified\">未分类 <span class=\"count\">0</span></div>\n</div>\n\n<div class=\"toolbar\">\n <div class=\"search-inline\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/></svg>\n <input class=\"input\" id=\"search-input\" placeholder=\"搜索资产名称、标签\">\n </div>\n\n <!-- ── 人物 tab 专属 ── -->\n <div class=\"chip-wrap\" data-key=\"gender\" data-tabs=\"people\">\n <button class=\"chip\" type=\"button\"><span class=\"chip-label\">性别</span> <svg class=\"caret\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></button>\n <div class=\"chip-menu\"></div>\n </div>\n <div class=\"chip-wrap\" data-key=\"age\" data-tabs=\"people\">\n <button class=\"chip\" type=\"button\"><span class=\"chip-label\">年龄段</span> <svg class=\"caret\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></button>\n <div class=\"chip-menu\"></div>\n </div>\n <div class=\"chip-wrap\" data-key=\"role\" data-tabs=\"people\">\n <button class=\"chip\" type=\"button\"><span class=\"chip-label\">角色标签</span> <svg class=\"caret\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></button>\n <div class=\"chip-menu\"></div>\n </div>\n\n <!-- ── 场景 tab 专属 ── -->\n <div class=\"chip-wrap\" data-key=\"sceneType\" data-tabs=\"scenes\">\n <button class=\"chip\" type=\"button\"><span class=\"chip-label\">场景类型</span> <svg class=\"caret\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></button>\n <div class=\"chip-menu\"></div>\n </div>\n\n <!-- ── 商品图 tab 专属 ── -->\n <div class=\"chip-wrap\" data-key=\"product\" data-tabs=\"products\">\n <button class=\"chip\" type=\"button\"><span class=\"chip-label\">关联商品</span> <svg class=\"caret\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></button>\n <div class=\"chip-menu\"></div>\n </div>\n\n <!-- ── 成片 tab 专属 ── -->\n <div class=\"chip-wrap\" data-key=\"project\" data-tabs=\"finals\">\n <button class=\"chip\" type=\"button\"><span class=\"chip-label\">关联项目</span> <svg class=\"caret\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></button>\n <div class=\"chip-menu\"></div>\n </div>\n <div class=\"chip-wrap\" data-key=\"duration\" data-tabs=\"finals\">\n <button class=\"chip\" type=\"button\"><span class=\"chip-label\">时长</span> <svg class=\"caret\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></button>\n <div class=\"chip-menu\"></div>\n </div>\n\n <!-- ── 我的上传 tab 专属 ── -->\n <div class=\"chip-wrap\" data-key=\"kind\" data-tabs=\"uploads\">\n <button class=\"chip\" type=\"button\"><span class=\"chip-label\">资产类型</span> <svg class=\"caret\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></button>\n <div class=\"chip-menu\"></div>\n </div>\n\n <!-- ── 共用(人物 / 场景 / 商品图 / 我的上传)── -->\n <div class=\"chip-wrap\" data-key=\"source\" data-tabs=\"people scenes products uploads\">\n <button class=\"chip\" type=\"button\"><span class=\"chip-label\">来源</span> <svg class=\"caret\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></button>\n <div class=\"chip-menu\"></div>\n </div>\n\n <button class=\"clear-filters\" id=\"clear-filters\" type=\"button\" hidden>\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 4l8 8M12 4l-8 8\"/></svg>\n 清空筛选\n </button>\n <span class=\"spacer\"></span>\n\n <!-- ── 排序(所有 tab 共用)── -->\n <div class=\"chip-wrap\" data-key=\"sort\" data-tabs=\"all\">\n <button class=\"chip\" type=\"button\"><span class=\"chip-label\">最近使用</span> <svg class=\"caret\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></button>\n <div class=\"chip-menu align-right\"></div>\n </div>\n</div>\n\n<div class=\"result-meta\" id=\"result-meta\">// 显示 <span class=\"count\">0</span> / 0 个资产</div>\n\n<!-- ============ 人物 (8) ============ -->\n<div class=\"asset-grid\" data-tab=\"people\" id=\"grid-people\">\n <div class=\"asset-card\" data-name=\"林夕\" data-gender=\"女\" data-age=\"青年\" data-role=\"都市白领\" data-source=\"AI 生成\" data-used=\"4\" data-added=\"20260513\" onclick=\"Shell.toast('查看资产', '林夕')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">林夕 · 都市白领</span></div>\n <div class=\"asset-body\">\n <div class=\"asset-name\">林夕</div>\n <div class=\"asset-meta\">女 · 青年 · 都市白领 · 用过 4 次</div>\n </div>\n </div>\n <div class=\"asset-card\" data-name=\"阿楠\" data-gender=\"女\" data-age=\"青年\" data-role=\"都市白领\" data-source=\"AI 生成\" data-used=\"2\" data-added=\"20260507\" onclick=\"Shell.toast('查看资产', '阿楠')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">阿楠 · 同事女</span></div>\n <div class=\"asset-body\">\n <div class=\"asset-name\">阿楠</div>\n <div class=\"asset-meta\">女 · 青年 · 都市白领 · 用过 2 次</div>\n </div>\n </div>\n <div class=\"asset-card\" data-name=\"小七\" data-gender=\"女\" data-age=\"青年\" data-role=\"学生\" data-source=\"AI 生成\" data-used=\"3\" data-added=\"20260512\" onclick=\"Shell.toast('查看资产', '小七')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">小七 · 学生女</span></div>\n <div class=\"asset-body\">\n <div class=\"asset-name\">小七</div>\n <div class=\"asset-meta\">女 · 青年 · 学生 · 用过 3 次</div>\n </div>\n </div>\n <div class=\"asset-card\" data-name=\"阿杰\" data-gender=\"男\" data-age=\"青年\" data-role=\"都市白领\" data-source=\"AI 生成\" data-used=\"2\" data-added=\"20260428\" onclick=\"Shell.toast('查看资产', '阿杰')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">阿杰 · 通勤男</span></div>\n <div class=\"asset-body\">\n <div class=\"asset-name\">阿杰</div>\n <div class=\"asset-meta\">男 · 青年 · 都市白领 · 用过 2 次</div>\n </div>\n </div>\n <div class=\"asset-card\" data-name=\"妈妈 · 王姐\" data-gender=\"女\" data-age=\"中年\" data-role=\"居家\" data-source=\"手动上传\" data-triview=\"0\" data-used=\"1\" data-added=\"20260415\" onclick=\"Shell.toast('查看资产', '王姐')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\">\n <span class=\"tri-missing-badge\" tabindex=\"0\" role=\"button\" aria-label=\"缺三视图,查看说明\">\n <span class=\"ico\" aria-hidden=\"true\"></span>\n <span class=\"lbl-mono\">缺三视图</span>\n <span class=\"tri-missing-pop\" role=\"tooltip\">\n <span class=\"pop-h\">\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 9v4M12 17h.01\"/><path d=\"M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z\"/></svg>\n MISSING TRI-VIEW\n </span>\n <span class=\"pop-body\">手动上传的人物未生成 <b>正 / 侧 / 背</b> 三视图。直接进入图片或视频生成,人脸/服饰一致性可能下降。</span>\n <span class=\"pop-tip\">建议:前往 <b>图片生成</b> 先补齐三视图,再发起后续生成。</span>\n </span>\n </span>\n <span class=\"ph-frame\">妈妈 · 居家</span>\n </div>\n <div class=\"asset-body\">\n <div class=\"asset-name\">妈妈 · 王姐</div>\n <div class=\"asset-meta\">女 · 中年 · 居家 · 用过 1 次</div>\n </div>\n </div>\n <div class=\"asset-card\" data-name=\"阿强\" data-gender=\"男\" data-age=\"青年\" data-role=\"健身\" data-source=\"AI 生成\" data-used=\"2\" data-added=\"20260508\" onclick=\"Shell.toast('查看资产', '阿强')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">阿强 · 健身男</span></div>\n <div class=\"asset-body\">\n <div class=\"asset-name\">阿强</div>\n <div class=\"asset-meta\">男 · 青年 · 健身 · 用过 2 次</div>\n </div>\n </div>\n <div class=\"asset-card\" data-name=\"小苏\" data-gender=\"女\" data-age=\"青年\" data-role=\"文艺\" data-source=\"AI 生成\" data-used=\"1\" data-added=\"20260420\" onclick=\"Shell.toast('查看资产', '小苏')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">小苏 · 文艺女</span></div>\n <div class=\"asset-body\">\n <div class=\"asset-name\">小苏</div>\n <div class=\"asset-meta\">女 · 青年 · 文艺 · 用过 1 次</div>\n </div>\n </div>\n <div class=\"asset-card\" data-name=\"闺蜜组合\" data-gender=\"女\" data-age=\"青年\" data-role=\"都市白领\" data-source=\"AI 生成\" data-used=\"1\" data-added=\"20260511\" onclick=\"Shell.toast('查看资产', '闺蜜组合')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">闺蜜组合 · 双人</span></div>\n <div class=\"asset-body\">\n <div class=\"asset-name\">闺蜜组合</div>\n <div class=\"asset-meta\">女 · 青年 · 都市白领 · 用过 1 次</div>\n </div>\n </div>\n <div class=\"asset-card\" data-name=\"豆豆\" data-gender=\"女\" data-age=\"幼年\" data-role=\"居家\" data-source=\"AI 生成\" data-used=\"2\" data-added=\"20260509\" onclick=\"Shell.toast('查看资产', '豆豆')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">豆豆 · 幼儿</span></div>\n <div class=\"asset-body\">\n <div class=\"asset-name\">豆豆</div>\n <div class=\"asset-meta\">女 · 幼年 · 居家 · 用过 2 次</div>\n </div>\n </div>\n <div class=\"asset-card\" data-name=\"小宇\" data-gender=\"男\" data-age=\"少年\" data-role=\"学生\" data-source=\"AI 生成\" data-used=\"1\" data-added=\"20260502\" onclick=\"Shell.toast('查看资产', '小宇')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">小宇 · 中学生</span></div>\n <div class=\"asset-body\">\n <div class=\"asset-name\">小宇</div>\n <div class=\"asset-meta\">男 · 少年 · 学生 · 用过 1 次</div>\n </div>\n </div>\n <div class=\"asset-card\" data-name=\"李爷爷\" data-gender=\"男\" data-age=\"老年\" data-role=\"居家\" data-source=\"手动上传\" data-triview=\"0\" data-used=\"1\" data-added=\"20260418\" onclick=\"Shell.toast('查看资产', '李爷爷')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\">\n <span class=\"tri-missing-badge\" tabindex=\"0\" role=\"button\" aria-label=\"缺三视图,查看说明\">\n <span class=\"ico\" aria-hidden=\"true\"></span>\n <span class=\"lbl-mono\">缺三视图</span>\n <span class=\"tri-missing-pop\" role=\"tooltip\">\n <span class=\"pop-h\">\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 9v4M12 17h.01\"/><path d=\"M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z\"/></svg>\n MISSING TRI-VIEW\n </span>\n <span class=\"pop-body\">手动上传的人物未生成 <b>正 / 侧 / 背</b> 三视图。直接进入图片或视频生成,人脸/服饰一致性可能下降。</span>\n <span class=\"pop-tip\">建议:前往 <b>图片生成</b> 先补齐三视图,再发起后续生成。</span>\n </span>\n </span>\n <span class=\"ph-frame\">李爷爷 · 居家</span>\n </div>\n <div class=\"asset-body\">\n <div class=\"asset-name\">李爷爷</div>\n <div class=\"asset-meta\">男 · 老年 · 居家 · 用过 1 次</div>\n </div>\n </div>\n</div>\n\n<!-- ============ 场景 (12) ============ -->\n<div class=\"asset-grid\" data-tab=\"scenes\" id=\"grid-scenes\" hidden>\n <div class=\"asset-card\" data-name=\"卧室·暖光\" data-scene-type=\"卧室\" data-source=\"AI 生成\" data-used=\"6\" data-added=\"20260513\" onclick=\"Shell.toast('查看资产', '卧室·暖光')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">卧室 · 暖光</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">卧室·暖光</div><div class=\"asset-meta\">卧室 · AI 生成 · 用过 6 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"卧室·冷调\" data-scene-type=\"卧室\" data-source=\"AI 生成\" data-used=\"3\" data-added=\"20260507\" onclick=\"Shell.toast('查看资产', '卧室·冷调')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">卧室 · 冷调</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">卧室·冷调</div><div class=\"asset-meta\">卧室 · AI 生成 · 用过 3 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"浴室·梳妆台\" data-scene-type=\"浴室\" data-source=\"AI 生成\" data-used=\"4\" data-added=\"20260510\" onclick=\"Shell.toast('查看资产', '浴室·梳妆台')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">浴室 · 梳妆台</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">浴室·梳妆台</div><div class=\"asset-meta\">浴室 · AI 生成 · 用过 4 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"客厅·北欧\" data-scene-type=\"客厅\" data-source=\"AI 生成\" data-used=\"5\" data-added=\"20260512\" onclick=\"Shell.toast('查看资产', '客厅·北欧')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">客厅 · 北欧</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">客厅·北欧</div><div class=\"asset-meta\">客厅 · AI 生成 · 用过 5 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"客厅·中古\" data-scene-type=\"客厅\" data-source=\"手动上传\" data-used=\"1\" data-added=\"20260418\" onclick=\"Shell.toast('查看资产', '客厅·中古')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">客厅 · 中古</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">客厅·中古</div><div class=\"asset-meta\">客厅 · 手动上传 · 用过 1 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"厨房·中岛\" data-scene-type=\"厨房\" data-source=\"AI 生成\" data-used=\"3\" data-added=\"20260509\" onclick=\"Shell.toast('查看资产', '厨房·中岛')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">厨房 · 中岛</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">厨房·中岛</div><div class=\"asset-meta\">厨房 · AI 生成 · 用过 3 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"办公室·开放\" data-scene-type=\"办公室\" data-source=\"AI 生成\" data-used=\"2\" data-added=\"20260506\" onclick=\"Shell.toast('查看资产', '办公室·开放')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">办公室 · 开放</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">办公室·开放</div><div class=\"asset-meta\">办公室 · AI 生成 · 用过 2 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"办公室·会议室\" data-scene-type=\"办公室\" data-source=\"AI 生成\" data-used=\"1\" data-added=\"20260425\" onclick=\"Shell.toast('查看资产', '办公室·会议室')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">办公室 · 会议室</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">办公室·会议室</div><div class=\"asset-meta\">办公室 · AI 生成 · 用过 1 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"咖啡店·窗边\" data-scene-type=\"咖啡店\" data-source=\"AI 生成\" data-used=\"4\" data-added=\"20260511\" onclick=\"Shell.toast('查看资产', '咖啡店·窗边')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">咖啡店 · 窗边</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">咖啡店·窗边</div><div class=\"asset-meta\">咖啡店 · AI 生成 · 用过 4 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"街景·夜\" data-scene-type=\"街景\" data-source=\"AI 生成\" data-used=\"2\" data-added=\"20260430\" onclick=\"Shell.toast('查看资产', '街景·夜')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">街景 · 夜</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">街景·夜</div><div class=\"asset-meta\">街景 · AI 生成 · 用过 2 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"健身房·器械\" data-scene-type=\"健身房\" data-source=\"AI 生成\" data-used=\"3\" data-added=\"20260508\" onclick=\"Shell.toast('查看资产', '健身房·器械')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">健身房 · 器械</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">健身房·器械</div><div class=\"asset-meta\">健身房 · AI 生成 · 用过 3 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"街景·日\" data-scene-type=\"街景\" data-source=\"手动上传\" data-used=\"1\" data-added=\"20260422\" onclick=\"Shell.toast('查看资产', '街景·日')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">街景 · 日</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">街景·日</div><div class=\"asset-meta\">街景 · 手动上传 · 用过 1 次</div></div>\n </div>\n</div>\n\n<!-- ============ 商品图 (12) ============ -->\n<div class=\"asset-grid\" data-tab=\"products\" id=\"grid-products\" hidden>\n <div class=\"asset-card\" data-name=\"补水面膜 · 主图\" data-product=\"透真补水面膜\" data-source=\"商品库引用\" data-used=\"5\" data-added=\"20260513\" onclick=\"Shell.toast('查看资产', '补水面膜 主图')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">补水面膜 · 主图</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">补水面膜 · 主图</div><div class=\"asset-meta\">透真补水面膜 · 库引用 · 用过 5 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"补水面膜 · AI 优化\" data-product=\"透真补水面膜\" data-source=\"AI 优化\" data-used=\"3\" data-added=\"20260513\" onclick=\"Shell.toast('查看资产', '补水面膜 AI 优化')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">补水面膜 · AI 优化</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">补水面膜 · AI 优化</div><div class=\"asset-meta\">透真补水面膜 · AI 优化 · 用过 3 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"蓝牙耳机 · 主图\" data-product=\"南卡 Lite Pro\" data-source=\"商品库引用\" data-used=\"4\" data-added=\"20260507\" onclick=\"Shell.toast('查看资产', '蓝牙耳机 主图')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">蓝牙耳机 · 主图</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">蓝牙耳机 · 主图</div><div class=\"asset-meta\">南卡 Lite Pro · 库引用 · 用过 4 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"蓝牙耳机 · 场景图\" data-product=\"南卡 Lite Pro\" data-source=\"手动上传\" data-used=\"1\" data-added=\"20260507\" onclick=\"Shell.toast('查看资产', '蓝牙耳机 场景')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">蓝牙耳机 · 场景</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">蓝牙耳机 · 场景图</div><div class=\"asset-meta\">南卡 Lite Pro · 手动上传 · 用过 1 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"速食面 · 主图\" data-product=\"滋啦速食\" data-source=\"商品库引用\" data-used=\"3\" data-added=\"20260512\" onclick=\"Shell.toast('查看资产', '速食面 主图')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">速食面 · 主图</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">速食面 · 主图</div><div class=\"asset-meta\">滋啦速食 · 库引用 · 用过 3 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"速食面 · 加汤\" data-product=\"滋啦速食\" data-source=\"AI 优化\" data-used=\"2\" data-added=\"20260512\" onclick=\"Shell.toast('查看资产', '速食面 加汤')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">速食面 · 加汤</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">速食面 · 加汤</div><div class=\"asset-meta\">滋啦速食 · AI 优化 · 用过 2 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"防晒霜 · 主图\" data-product=\"透真防晒霜\" data-source=\"商品库引用\" data-used=\"4\" data-added=\"20260510\" onclick=\"Shell.toast('查看资产', '防晒霜 主图')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">防晒霜 · 主图</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">防晒霜 · 主图</div><div class=\"asset-meta\">透真防晒霜 · 库引用 · 用过 4 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"防晒霜 · AI 优化\" data-product=\"透真防晒霜\" data-source=\"AI 优化\" data-used=\"3\" data-added=\"20260510\" onclick=\"Shell.toast('查看资产', '防晒霜 优化')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">防晒霜 · AI 优化</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">防晒霜 · AI 优化</div><div class=\"asset-meta\">透真防晒霜 · AI 优化 · 用过 3 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"咖啡冻干 · 主图\" data-product=\"三顿半同款\" data-source=\"商品库引用\" data-used=\"3\" data-added=\"20260509\" onclick=\"Shell.toast('查看资产', '咖啡冻干 主图')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">咖啡冻干 · 主图</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">咖啡冻干 · 主图</div><div class=\"asset-meta\">三顿半同款 · 库引用 · 用过 3 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"咖啡冻干 · 24 颗\" data-product=\"三顿半同款\" data-source=\"商品库引用\" data-used=\"2\" data-added=\"20260509\" onclick=\"Shell.toast('查看资产', '咖啡冻干 24 颗')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">咖啡冻干 · 24 颗</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">咖啡冻干 · 24 颗</div><div class=\"asset-meta\">三顿半同款 · 库引用 · 用过 2 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"空气炸锅 · 主图\" data-product=\"小熊 4L 空气炸锅\" data-source=\"商品库引用\" data-used=\"2\" data-added=\"20260504\" onclick=\"Shell.toast('查看资产', '空气炸锅 主图')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">空气炸锅 · 主图</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">空气炸锅 · 主图</div><div class=\"asset-meta\">小熊 4L · 库引用 · 用过 2 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"瑜伽裤 · 模特图\" data-product=\"露露同款瑜伽裤\" data-source=\"手动上传\" data-used=\"3\" data-added=\"20260506\" onclick=\"Shell.toast('查看资产', '瑜伽裤 模特')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">瑜伽裤 · 模特</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">瑜伽裤 · 模特图</div><div class=\"asset-meta\">露露同款瑜伽裤 · 手动上传 · 用过 3 次</div></div>\n </div>\n</div>\n\n<!-- ============ 成片 (8) ============ -->\n<div class=\"asset-grid video-grid\" data-tab=\"finals\" id=\"grid-finals\" hidden>\n <div class=\"asset-card video\" data-name=\"蓝牙耳机 · 开箱测评\" data-project=\"蓝牙耳机 · 开箱测评\" data-duration=\"60s\" data-used=\"3\" data-added=\"20260507\" onclick=\"Shell.toast('打开成片', '蓝牙耳机 · 开箱测评')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">9:16 · 60s</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">蓝牙耳机 · 开箱测评</div><div class=\"asset-meta\">南卡 Lite Pro · 60s · 5 月 7 日</div></div>\n </div>\n <div class=\"asset-card video\" data-name=\"瑜伽裤 · 通勤穿搭\" data-project=\"瑜伽裤 · 通勤穿搭\" data-duration=\"45s\" data-used=\"2\" data-added=\"20260506\" onclick=\"Shell.toast('打开成片', '瑜伽裤 · 通勤穿搭')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">9:16 · 45s</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">瑜伽裤 · 通勤穿搭</div><div class=\"asset-meta\">露露同款 · 45s · 5 月 6 日</div></div>\n </div>\n <div class=\"asset-card video\" data-name=\"空气炸锅 · 小户型\" data-project=\"空气炸锅 · 小户型\" data-duration=\"30s\" data-used=\"2\" data-added=\"20260504\" onclick=\"Shell.toast('打开成片', '空气炸锅 · 小户型')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">9:16 · 30s</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">空气炸锅 · 小户型</div><div class=\"asset-meta\">小熊 4L · 30s · 5 月 4 日</div></div>\n </div>\n <div class=\"asset-card video\" data-name=\"补水面膜 · 痛点种草 v1\" data-project=\"补水面膜 · 痛点种草 v1\" data-duration=\"60s\" data-used=\"2\" data-added=\"20260428\" onclick=\"Shell.toast('打开成片', '补水面膜 v1')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">9:16 · 60s</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">补水面膜 · 痛点种草 v1</div><div class=\"asset-meta\">透真补水面膜 · 60s · 4 月 28 日</div></div>\n </div>\n <div class=\"asset-card video\" data-name=\"防晒霜 · 通勤对比\" data-project=\"防晒霜 · 通勤对比\" data-duration=\"60s\" data-used=\"1\" data-added=\"20260425\" onclick=\"Shell.toast('打开成片', '防晒霜 · 通勤对比')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">9:16 · 60s</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">防晒霜 · 通勤对比</div><div class=\"asset-meta\">透真防晒霜 · 60s · 4 月 25 日</div></div>\n </div>\n <div class=\"asset-card video\" data-name=\"速食面 · 加班治愈\" data-project=\"速食面 · 加班治愈\" data-duration=\"30s\" data-used=\"1\" data-added=\"20260420\" onclick=\"Shell.toast('打开成片', '速食面 · 加班治愈')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">9:16 · 30s</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">速食面 · 加班治愈</div><div class=\"asset-meta\">滋啦速食 · 30s · 4 月 20 日</div></div>\n </div>\n <div class=\"asset-card video\" data-name=\"咖啡 · 早八剧情\" data-project=\"咖啡 · 早八剧情\" data-duration=\"45s\" data-used=\"2\" data-added=\"20260418\" onclick=\"Shell.toast('打开成片', '咖啡 · 早八剧情')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">9:16 · 45s</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">咖啡 · 早八剧情</div><div class=\"asset-meta\">三顿半同款 · 45s · 4 月 18 日</div></div>\n </div>\n <div class=\"asset-card video\" data-name=\"收纳 · 北欧\" data-project=\"收纳 · 北欧\" data-duration=\"15s\" data-used=\"1\" data-added=\"20260410\" onclick=\"Shell.toast('打开成片', '收纳 · 北欧')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">9:16 · 15s</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">收纳 · 北欧</div><div class=\"asset-meta\">家居好物 · 15s · 4 月 10 日</div></div>\n </div>\n</div>\n\n<!-- ============ 我的上传 (3) ============ -->\n<div class=\"asset-grid\" data-tab=\"uploads\" id=\"grid-uploads\" hidden>\n <div class=\"asset-card\" data-name=\"林夕 · 主播照\" data-kind=\"人物\" data-source=\"手动上传\" data-used=\"4\" data-added=\"20260513\" onclick=\"Shell.toast('查看资产', '林夕 · 主播照')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">林夕 · 主播照</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">林夕 · 主播照</div><div class=\"asset-meta\">人物 · 手动上传 · 用过 4 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"卧室 · 实拍\" data-kind=\"场景\" data-source=\"手动上传\" data-used=\"2\" data-added=\"20260510\" onclick=\"Shell.toast('查看资产', '卧室 · 实拍')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">卧室 · 实拍</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">卧室 · 实拍</div><div class=\"asset-meta\">场景 · 手动上传 · 用过 2 次</div></div>\n </div>\n <div class=\"asset-card\" data-name=\"防晒霜 · 官方图\" data-kind=\"商品\" data-source=\"手动上传\" data-used=\"3\" data-added=\"20260507\" onclick=\"Shell.toast('查看资产', '防晒霜 · 官方图')\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">防晒霜 · 官方图</span></div>\n <div class=\"asset-body\"><div class=\"asset-name\">防晒霜 · 官方图</div><div class=\"asset-meta\">商品 · 手动上传 · 用过 3 次</div></div>\n </div>\n</div>\n\n<!-- ============ 未分类(由图片优化\"加入资产库\"持久化进来) ============ -->\n<div class=\"asset-grid\" data-tab=\"unclassified\" id=\"grid-unclassified\" hidden></div>\n\n<div class=\"empty-state\" id=\"empty\">\n <div class=\"ic-empty\">\n <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/></svg>\n </div>\n <h3>没有匹配的资产</h3>\n <p>// 试试切换 tab 或修改搜索词</p>\n</div>\n\n<!-- ============ 分页 (吸底) ============ -->\n<div class=\"pagination\" id=\"pagination\" hidden>\n <span class=\"total\">共 <b id=\"page-total\">0</b> 条</span>\n <button class=\"page-size\" type=\"button\" id=\"page-size-btn\" title=\"切换每页条数\">\n <span id=\"page-size-label\">12 条/页</span>\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg>\n </button>\n <span class=\"pages\" id=\"page-list\"></span>\n <span class=\"jump\">跳至 <input type=\"number\" min=\"1\" value=\"1\" id=\"page-jump\"> 页</span>\n</div>\n\n<!-- ============ 上传资产 Modal ============ -->\n<div class=\"modal-bg\" id=\"upload-modal-bg\" onclick=\"if(event.target===this)Shell.closeModal('upload-modal-bg')\">\n <div class=\"modal upload-modal\">\n <span class=\"corner-tr\"></span>\n <span class=\"corner-bl\"></span>\n\n <div class=\"modal-h\">\n <div class=\"ic-m\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12\"/></svg>\n </div>\n <div class=\"ti\">上传资产<span>// 跨项目共享 · 不消耗 token</span></div>\n <button class=\"modal-x\" type=\"button\" onclick=\"Shell.closeModal('upload-modal-bg')\" aria-label=\"关闭\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"><path d=\"M18 6L6 18M6 6l12 12\"/></svg>\n </button>\n </div>\n\n <div class=\"modal-b\">\n <div class=\"field\">\n <label class=\"field-label\">资产类型<span class=\"req\">*</span></label>\n <select class=\"select\" id=\"upload-kind\">\n <option value=\"people\">人物</option>\n <option value=\"scenes\">场景</option>\n <option value=\"products\">商品图</option>\n <option value=\"finals\">成片(视频)</option>\n </select>\n </div>\n\n <div class=\"field\">\n <label class=\"field-label\">资产文件<span class=\"req\">*</span></label>\n <input type=\"file\" id=\"upload-file\" accept=\"image/*\" hidden>\n <div class=\"upload-zone\" id=\"upload-zone\">\n <span class=\"uz-ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12\"/></svg>\n </span>\n <span id=\"upload-zone-text\">点击或拖拽上传图片</span>\n <span class=\"uz-hint\" id=\"upload-zone-hint\">// JPG / PNG / WEBP · 单文件</span>\n </div>\n <div class=\"upload-preview\" id=\"upload-preview\" hidden>\n <img id=\"upload-preview-img\" alt=\"预览\">\n <video id=\"upload-preview-video\" controls hidden></video>\n <button class=\"preview-x\" type=\"button\" id=\"upload-preview-x\" aria-label=\"移除\">\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"><path d=\"M4 4l8 8M12 4l-8 8\"/></svg>\n </button>\n </div>\n </div>\n\n <div class=\"field\">\n <label class=\"field-label\">资产名称<span class=\"req\">*</span></label>\n <input class=\"input\" id=\"upload-name\" placeholder=\"例: 林夕 · 都市白领\">\n </div>\n\n <!-- 人物 字段 -->\n <div class=\"field\" data-fields=\"people\">\n <label class=\"field-label\">性别</label>\n <select class=\"select\" id=\"upload-gender\">\n <option value=\"女\">女</option>\n <option value=\"男\">男</option>\n </select>\n </div>\n <div class=\"field\" data-fields=\"people\">\n <label class=\"field-label\">年龄段</label>\n <select class=\"select\" id=\"upload-age\">\n <option value=\"幼年\">幼年</option>\n <option value=\"少年\">少年</option>\n <option value=\"青年\" selected>青年</option>\n <option value=\"中年\">中年</option>\n <option value=\"老年\">老年</option>\n </select>\n </div>\n <div class=\"field\" data-fields=\"people\">\n <label class=\"field-label\">角色标签</label>\n <input class=\"input\" id=\"upload-role\" placeholder=\"例: 都市白领 / 学生 / 居家\">\n </div>\n\n <!-- 场景 字段 -->\n <div class=\"field\" data-fields=\"scenes\">\n <label class=\"field-label\">场景类型<span class=\"req\">*</span></label>\n <input class=\"input\" id=\"upload-scene-type\" placeholder=\"例: 卧室 / 客厅 / 办公室\">\n </div>\n\n <!-- 商品图 字段 -->\n <div class=\"field\" data-fields=\"products\">\n <label class=\"field-label\">关联商品<span class=\"req\">*</span></label>\n <select class=\"select\" id=\"upload-product\">\n <option value=\"\">— 选择商品 —</option>\n <option>透真补水面膜</option>\n <option>南卡 Lite Pro</option>\n <option>滋啦速食</option>\n <option>透真防晒霜</option>\n <option>三顿半同款</option>\n <option>小熊 4L 空气炸锅</option>\n <option>露露同款瑜伽裤</option>\n </select>\n </div>\n\n <!-- 成片 字段 -->\n <div class=\"field\" data-fields=\"finals\">\n <label class=\"field-label\">关联项目</label>\n <input class=\"input\" id=\"upload-project\" placeholder=\"例: 蓝牙耳机 · 开箱测评\">\n </div>\n <div class=\"field\" data-fields=\"finals\">\n <label class=\"field-label\">时长</label>\n <select class=\"select\" id=\"upload-duration\">\n <option value=\"15s\">15 秒</option>\n <option value=\"30s\">30 秒</option>\n <option value=\"45s\">45 秒</option>\n <option value=\"60s\" selected>60 秒</option>\n </select>\n </div>\n </div>\n\n <div class=\"modal-f\">\n <span class=\"modal-meta\">// 跨项目共享 · <span class=\"accent\">不消耗 token</span></span>\n <button class=\"btn\" type=\"button\" onclick=\"Shell.closeModal('upload-modal-bg')\">取消</button>\n <button class=\"btn btn-primary\" id=\"upload-submit\" type=\"button\" disabled>\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 12l5 5L20 6\"/></svg>\n 上传到资产库\n </button>\n </div>\n </div>\n</div>\n\n</div>\n\n<!-- ===== 删除确认 modal ===== -->\n<div class=\"modal-bg\" id=\"del-confirm-bg\">\n <div class=\"modal\" role=\"dialog\">\n <span class=\"corner-tr\" aria-hidden></span>\n <span class=\"corner-bl\" aria-hidden></span>\n <div class=\"modal-h\">\n <div class=\"ic-m\" style=\"background:var(--crimson-bg,#fdebea);color:var(--accent-crimson,#c43d3d)\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg>\n </div>\n <div class=\"ti\">确认删除资产<span>// CONFIRM DELETE</span></div>\n </div>\n <div class=\"modal-b\" id=\"del-confirm-body\">即将删除该资产。</div>\n <div class=\"modal-f\" id=\"del-confirm-foot\">\n <button class=\"btn\" type=\"button\" id=\"del-confirm-cancel\">取消</button>\n <button class=\"btn\" type=\"button\" id=\"del-confirm-ok\" style=\"background:var(--accent-crimson,#c43d3d);color:var(--accent-white);border-color:var(--accent-crimson,#c43d3d)\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/></svg>\n 确认删除\n </button>\n </div>\n </div>\n</div>\n\n<!-- ===== bulk-bar ===== -->\n<div class=\"bulk-bar\" id=\"bulk-bar\">\n <span class=\"ct\">已选 <b id=\"bulk-count\">0</b> 项</span>\n <button class=\"clear-sel\" type=\"button\" id=\"bulk-clear\">清空</button>\n <span class=\"sep\"></span>\n <button class=\"danger\" type=\"button\" id=\"bulk-del\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg>\n 删除所选\n </button>\n <div class=\"move-wrap\">\n <button type=\"button\" id=\"bulk-move\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 18l6-6-6-6\"/><path d=\"M3 12h12\"/></svg>\n 移动到\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" style=\"margin-left:2px\"><path d=\"M4 10l4-4 4 4\"/></svg>\n </button>\n <div class=\"move-menu\" id=\"bulk-move-menu\"></div>\n </div>\n <button type=\"button\" id=\"bulk-exit\">完成</button>\n</div>\n\n<script src=\"/exact/assets/icons.js?v=2026052608\"></script>\n<script src=\"/exact/assets/shell.js?v=2026060101\"></script>\n<script>\nShell.render({ active: 'library', crumbs: [{ label: '工作台', href: 'index.html' }, { label: '资产库' }] });\n\n/* ─── 给所有资产卡注入下载按钮 · PRD §6.5 所有中间产物可下载 ─── */\n(function injectDownloadBtns() {\n const dlSvg = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"/><polyline points=\"7 10 12 15 17 10\"/><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"/></svg>';\n document.querySelectorAll('.asset-card').forEach(card => {\n if (card.querySelector('.card-dl-btn')) return;\n const btn = document.createElement('button');\n btn.className = 'card-dl-btn';\n btn.type = 'button';\n btn.title = '下载资产';\n btn.innerHTML = dlSvg;\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n const name = card.dataset.name || '资产';\n // 推测卡片类型用作 mono 后缀\n const grid = card.closest('.asset-grid');\n const kind = grid ? grid.dataset.tab : '';\n const kindLabel = { people: '人物 · PNG', scenes: '场景 · PNG', products: '商品 · PNG', finals: '成片 · MP4 1080p', uploads: '原始素材' }[kind] || '资产';\n Shell.toast('下载中', name + ' · ' + kindLabel);\n });\n card.appendChild(btn);\n });\n})();\n\n// ============== State ==============\nconst TAB_KEYS = ['people', 'scenes', 'products', 'finals', 'uploads', 'unclassified'];\n\n/* ============== 加载图片优化\"加入资产库\"持久化数据 ==============\n image-optimize.html 把图保存到 localStorage['fs-library-unclassified']\n 这里读出后注入到 #grid-unclassified ============== */\n(function loadUnclassified() {\n let list;\n try { list = JSON.parse(localStorage.getItem('fs-library-unclassified') || '[]'); } catch (e) { list = []; }\n if (!Array.isArray(list) || !list.length) return;\n const grid = document.getElementById('grid-unclassified');\n if (!grid) return;\n function esc(s) { return String(s == null ? '' : s).replace(/[&<>\"']/g, c => ({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c])); }\n function fmtDate(ts) {\n if (!ts) return '';\n const d = new Date(ts), z = n => (n < 10 ? '0' + n : '' + n);\n return d.getFullYear() + z(d.getMonth() + 1) + z(d.getDate());\n }\n list.forEach(it => {\n const card = document.createElement('div');\n card.className = 'asset-card';\n card.dataset.name = it.name || '未命名';\n card.dataset.kind = '未分类';\n card.dataset.source = it.source || '图片优化';\n card.dataset.used = '0';\n card.dataset.added = fmtDate(it.addedAt);\n card.dataset.libId = it.id || '';\n card.innerHTML = `\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除资产\" onclick=\"event.stopPropagation();\" data-action=\"delete-asset\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder asset-thumb\"><span class=\"ph-frame\">${esc(it.name || '未命名')}</span></div>\n <div class=\"asset-body\">\n <div class=\"asset-name\">${esc(it.name || '未命名')}</div>\n <div class=\"asset-meta\">未分类 · ${esc(it.source || '图片优化')} · ${esc(it.ratio || '')}</div>\n </div>\n `;\n card.addEventListener('click', () => {\n if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('查看资产', it.name || '未分类素材');\n });\n grid.appendChild(card);\n });\n})();\nconst PAGE_SIZES = [12, 24, 48, 96];\nconst state = {\n tab: 'people',\n search: '',\n // 人物\n gender: 'all', age: 'all', role: 'all',\n // 场景\n sceneType: 'all',\n // 商品图\n product: 'all',\n // 成片\n project: 'all', duration: 'all',\n // 我的上传\n kind: 'all',\n // 通用\n source: 'all',\n sort: 'used-desc',\n // 分页\n page: 1,\n pageSize: 12,\n};\n\nconst CHIP_DEFAULT_LABEL = {\n gender: '性别', age: '年龄段', role: '角色标签',\n sceneType: '场景类型',\n product: '关联商品',\n project: '关联项目', duration: '时长',\n kind: '资产类型',\n source: '来源',\n};\n\nconst SORT_LABEL = {\n 'used-desc': '最近使用',\n 'added-desc': '最近添加',\n 'ref-desc': '引用次数',\n};\n\nconst checkSvg = '<svg class=\"mi-check\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg>';\n\n// ============== Card pools per tab ==============\nconst cardsByTab = {};\nTAB_KEYS.forEach(t => {\n cardsByTab[t] = [...document.querySelectorAll(`#grid-${t} .asset-card`)];\n});\n\n// 同步 tab count + 副标题计数\nTAB_KEYS.forEach(t => {\n const tab = document.querySelector(`.tab[data-tab=\"${t}\"] .count`);\n if (tab) tab.textContent = cardsByTab[t].length;\n});\ndocument.getElementById('sub-people').textContent = cardsByTab.people.length;\ndocument.getElementById('sub-scenes').textContent = cardsByTab.scenes.length;\ndocument.getElementById('sub-products').textContent = cardsByTab.products.length;\ndocument.getElementById('sub-finals').textContent = cardsByTab.finals.length;\n\n// 同步 sidebar 徽章(资产总数)\nconst sidebarBadge = document.querySelector('aside.sidebar a[href=\"library.html\"] .pill-mini');\nif (sidebarBadge) {\n const total = TAB_KEYS.reduce((s, t) => s + cardsByTab[t].length, 0);\n sidebarBadge.textContent = total;\n}\n\n// ============== 构建下拉菜单 ==============\nfunction uniqueValues(tab, attr) {\n const set = new Set();\n cardsByTab[tab].forEach(c => {\n const v = c.dataset[attr];\n if (v) set.add(v);\n });\n return [...set];\n}\n\nfunction buildMenu(key, options, defaultLabel) {\n const wrap = document.querySelector(`.chip-wrap[data-key=\"${key}\"]`);\n if (!wrap) return;\n const menu = wrap.querySelector('.chip-menu');\n const all = `<div class=\"mi selected\" data-value=\"all\">${checkSvg}<span>${defaultLabel}</span></div><div class=\"mi-sep\"></div>`;\n const items = options.map(o => `<div class=\"mi\" data-value=\"${o.value}\">${checkSvg}<span>${o.label}</span></div>`).join('');\n menu.innerHTML = all + items;\n}\n\n// 人物\nbuildMenu('gender', [\n { value: '女', label: '女' },\n { value: '男', label: '男' },\n], '全部性别');\nbuildMenu('age', [\n { value: '幼年', label: '幼年' },\n { value: '少年', label: '少年' },\n { value: '青年', label: '青年' },\n { value: '中年', label: '中年' },\n { value: '老年', label: '老年' },\n], '全部年龄段');\nbuildMenu('role', uniqueValues('people', 'role').map(v => ({ value: v, label: v })), '全部角色');\n\n// 场景\nbuildMenu('sceneType', uniqueValues('scenes', 'sceneType').map(v => ({ value: v, label: v })), '全部场景');\n\n// 商品图\nbuildMenu('product', uniqueValues('products', 'product').map(v => ({ value: v, label: v })), '全部商品');\n\n// 成片\nbuildMenu('project', uniqueValues('finals', 'project').map(v => ({ value: v, label: v })), '全部项目');\nbuildMenu('duration', [\n { value: '15s', label: '15 秒' },\n { value: '30s', label: '30 秒' },\n { value: '45s', label: '45 秒' },\n { value: '60s', label: '60 秒' },\n], '全部时长');\n\n// 我的上传\nbuildMenu('kind', [\n { value: '人物', label: '人物' },\n { value: '场景', label: '场景' },\n { value: '商品', label: '商品' },\n], '全部类型');\n\n// 通用·来源(按当前 tab 的实际数据动态构建)\nfunction rebuildSourceMenu() {\n const sources = uniqueValues(state.tab, 'source');\n buildMenu('source', sources.map(v => ({ value: v, label: v })), '全部来源');\n syncChipUI('source');\n}\nrebuildSourceMenu();\n\n// 排序(无\"全部\",默认第一项选中)\nconst sortWrap = document.querySelector('.chip-wrap[data-key=\"sort\"]');\nsortWrap.querySelector('.chip-menu').innerHTML = Object.entries(SORT_LABEL).map(([v, l], i) =>\n `<div class=\"mi${i === 0 ? ' selected' : ''}\" data-value=\"${v}\">${checkSvg}<span>${l}</span></div>`\n).join('');\n\n// ============== Apply ==============\nfunction applyFilter() {\n // 网格切换\n TAB_KEYS.forEach(t => {\n const g = document.getElementById(`grid-${t}`);\n if (!g) return;\n g.hidden = t !== state.tab;\n });\n\n // chip-wrap 按当前 tab 显隐\n document.querySelectorAll('.chip-wrap').forEach(wrap => {\n const tabs = (wrap.dataset.tabs || '').split(' ');\n const show = tabs.includes('all') || tabs.includes(state.tab);\n wrap.style.display = show ? '' : 'none';\n });\n\n // 过滤\n const cards = cardsByTab[state.tab];\n const q = state.search.toLowerCase();\n let visible = [];\n cards.forEach(c => {\n let show = true;\n if (q) {\n const hay = `${c.dataset.name || ''} ${c.dataset.role || ''} ${c.dataset.sceneType || ''} ${c.dataset.product || ''} ${c.dataset.project || ''} ${c.dataset.source || ''}`.toLowerCase();\n if (!hay.includes(q)) show = false;\n }\n // 通用 source\n if (show && state.source !== 'all' && c.dataset.source !== state.source) show = false;\n\n if (state.tab === 'people') {\n if (show && state.gender !== 'all' && c.dataset.gender !== state.gender) show = false;\n if (show && state.age !== 'all' && c.dataset.age !== state.age) show = false;\n if (show && state.role !== 'all' && c.dataset.role !== state.role) show = false;\n } else if (state.tab === 'scenes') {\n if (show && state.sceneType !== 'all' && c.dataset.sceneType !== state.sceneType) show = false;\n } else if (state.tab === 'products') {\n if (show && state.product !== 'all' && c.dataset.product !== state.product) show = false;\n } else if (state.tab === 'finals') {\n if (show && state.project !== 'all' && c.dataset.project !== state.project) show = false;\n if (show && state.duration !== 'all' && c.dataset.duration !== state.duration) show = false;\n } else if (state.tab === 'uploads') {\n if (show && state.kind !== 'all' && c.dataset.kind !== state.kind) show = false;\n }\n\n c.style.display = show ? '' : 'none';\n if (show) visible.push(c);\n });\n\n // 排序\n const sorters = {\n 'used-desc': (a, b) => +b.dataset.used - +a.dataset.used,\n 'added-desc': (a, b) => +b.dataset.added - +a.dataset.added,\n 'ref-desc': (a, b) => +b.dataset.used - +a.dataset.used,\n };\n visible.sort(sorters[state.sort] || sorters['used-desc']);\n const grid = document.getElementById(`grid-${state.tab}`);\n visible.forEach(c => grid.appendChild(c));\n\n // 分页 · 在排序之后裁页, 把非当前页的卡片隐藏\n const totalVisible = visible.length;\n const totalPages = Math.max(1, Math.ceil(totalVisible / state.pageSize));\n if (state.page > totalPages) state.page = totalPages;\n if (state.page < 1) state.page = 1;\n const start = (state.page - 1) * state.pageSize;\n const end = start + state.pageSize;\n visible.forEach((c, i) => {\n if (i < start || i >= end) c.style.display = 'none';\n });\n\n // 计数 + 空状态\n const total = cards.length;\n document.getElementById('result-meta').innerHTML = `// 显示 <span class=\"count\">${totalVisible}</span> / ${total} 个资产`;\n const empty = document.getElementById('empty');\n if (totalVisible === 0) {\n empty.classList.add('show');\n grid.style.display = 'none';\n } else {\n empty.classList.remove('show');\n grid.style.display = '';\n }\n\n // 渲染分页器\n renderPagination(totalVisible, totalPages);\n\n // 是否有任意筛选生效 → 显示\"清空筛选\"\n const activeKeys = visibleFilterKeys();\n const hasFilter = state.search || activeKeys.some(k => state[k] !== 'all');\n document.getElementById('clear-filters').hidden = !hasFilter;\n}\n\n// ============== 分页器渲染 ==============\nfunction pageNumberList(cur, total) {\n // 智能省略号 · 始终显示首末页 + cur 前后各 1 页\n if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);\n const pages = new Set([1, total, cur, cur - 1, cur + 1]);\n if (cur <= 4) [2, 3, 4, 5].forEach(p => pages.add(p));\n if (cur >= total - 3) [total - 4, total - 3, total - 2, total - 1].forEach(p => pages.add(p));\n const sorted = [...pages].filter(p => p >= 1 && p <= total).sort((a, b) => a - b);\n const out = [];\n for (let i = 0; i < sorted.length; i++) {\n if (i > 0 && sorted[i] - sorted[i - 1] > 1) out.push('…');\n out.push(sorted[i]);\n }\n return out;\n}\n\nfunction renderPagination(totalVisible, totalPages) {\n const root = document.getElementById('pagination');\n if (!root) return;\n // 空结果或只有一页且 ≤ pageSize → 不显示(数据不够分页时没必要占视觉)\n if (totalVisible === 0 || (totalPages <= 1 && totalVisible <= state.pageSize)) {\n root.hidden = true;\n return;\n }\n root.hidden = false;\n document.getElementById('page-total').textContent = totalVisible;\n document.getElementById('page-size-label').textContent = `${state.pageSize} 条/页`;\n document.getElementById('page-jump').value = state.page;\n document.getElementById('page-jump').max = totalPages;\n\n const list = document.getElementById('page-list');\n const items = pageNumberList(state.page, totalPages);\n let html = `<button type=\"button\" data-page=\"prev\" ${state.page <= 1 ? 'disabled' : ''} aria-label=\"上一页\">\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M10 12L6 8l4-4\"/></svg>\n </button>`;\n items.forEach(p => {\n if (p === '…') html += `<span class=\"ellipsis\">…</span>`;\n else html += `<button type=\"button\" data-page=\"${p}\" ${p === state.page ? 'class=\"active\"' : ''}>${p}</button>`;\n });\n html += `<button type=\"button\" data-page=\"next\" ${state.page >= totalPages ? 'disabled' : ''} aria-label=\"下一页\">\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6 4l4 4-4 4\"/></svg>\n </button>`;\n list.innerHTML = html;\n}\n\nfunction visibleFilterKeys() {\n return [...document.querySelectorAll('.chip-wrap')]\n .filter(w => {\n const tabs = (w.dataset.tabs || '').split(' ');\n return (tabs.includes('all') || tabs.includes(state.tab)) && w.dataset.key !== 'sort';\n })\n .map(w => w.dataset.key);\n}\n\n// ============== Sync chip UI ==============\nfunction syncChipUI(key) {\n const wrap = document.querySelector(`.chip-wrap[data-key=\"${key}\"]`);\n if (!wrap) return;\n const label = wrap.querySelector('.chip-label');\n const chip = wrap.querySelector('.chip');\n const v = state[key];\n if (key === 'sort') {\n label.textContent = SORT_LABEL[v];\n } else if (v === 'all') {\n label.textContent = CHIP_DEFAULT_LABEL[key];\n chip.classList.remove('active');\n } else {\n // 找到对应 mi 的 label 文字(优先用菜单里的展示文本)\n const mi = wrap.querySelector(`.mi[data-value=\"${CSS.escape(v)}\"] span`);\n label.textContent = mi ? mi.textContent : v;\n chip.classList.add('active');\n }\n wrap.querySelectorAll('.mi').forEach(mi => mi.classList.toggle('selected', mi.dataset.value === v));\n}\n\n// ============== Bind ==============\ndocument.querySelectorAll('.chip-wrap').forEach(wrap => {\n const key = wrap.dataset.key;\n wrap.querySelector('.chip').addEventListener('click', e => {\n e.stopPropagation();\n const isOpen = wrap.classList.contains('open');\n document.querySelectorAll('.chip-wrap.open').forEach(w => w.classList.remove('open'));\n if (!isOpen) wrap.classList.add('open');\n });\n wrap.addEventListener('click', e => {\n const mi = e.target.closest('.mi');\n if (!mi) return;\n e.stopPropagation();\n state[key] = mi.dataset.value;\n state.page = 1;\n wrap.classList.remove('open');\n syncChipUI(key);\n applyFilter();\n });\n});\n\ndocument.addEventListener('click', () => {\n document.querySelectorAll('.chip-wrap.open').forEach(w => w.classList.remove('open'));\n});\n\n// Tab clicks\ndocument.querySelectorAll('#asset-tabs .tab').forEach(t => {\n t.addEventListener('click', () => {\n document.querySelectorAll('#asset-tabs .tab').forEach(x => x.classList.remove('active'));\n t.classList.add('active');\n state.tab = t.dataset.tab;\n state.page = 1;\n // 切 tab 时,清空本 tab 不再可见的筛选\n ['gender', 'age', 'role', 'sceneType', 'product', 'project', 'duration', 'kind', 'source'].forEach(k => {\n const wrap = document.querySelector(`.chip-wrap[data-key=\"${k}\"]`);\n if (!wrap) return;\n const tabs = (wrap.dataset.tabs || '').split(' ');\n const visible = tabs.includes('all') || tabs.includes(state.tab);\n if (!visible) { state[k] = 'all'; syncChipUI(k); }\n });\n // 来源选项随 tab 数据重新构建\n state.source = 'all';\n rebuildSourceMenu();\n applyFilter();\n });\n});\n\n// 搜索\ndocument.getElementById('search-input').addEventListener('input', e => {\n state.search = e.target.value.trim();\n state.page = 1;\n applyFilter();\n});\n\n// 清空筛选\ndocument.getElementById('clear-filters').addEventListener('click', () => {\n state.search = '';\n document.getElementById('search-input').value = '';\n ['gender', 'age', 'role', 'sceneType', 'product', 'project', 'duration', 'kind', 'source'].forEach(k => {\n state[k] = 'all';\n syncChipUI(k);\n });\n state.page = 1;\n applyFilter();\n Shell.toast('已清空筛选');\n});\n\n// 分页器 · 翻页按钮(事件委托)\ndocument.getElementById('page-list').addEventListener('click', e => {\n const btn = e.target.closest('button[data-page]');\n if (!btn || btn.disabled) return;\n const v = btn.dataset.page;\n const totalPages = +document.getElementById('page-jump').max || 1;\n if (v === 'prev') state.page = Math.max(1, state.page - 1);\n else if (v === 'next') state.page = Math.min(totalPages, state.page + 1);\n else state.page = +v;\n applyFilter();\n // 翻页后滚到顶部, 让首张卡片立刻可见\n window.scrollTo({ top: 0, behavior: 'smooth' });\n});\n\n// 分页器 · 每页条数(循环切换 12 → 24 → 48 → 96 → 12)\ndocument.getElementById('page-size-btn').addEventListener('click', () => {\n const i = PAGE_SIZES.indexOf(state.pageSize);\n state.pageSize = PAGE_SIZES[(i + 1) % PAGE_SIZES.length];\n state.page = 1;\n applyFilter();\n});\n\n// 分页器 · 跳转\nconst _jumpEl = document.getElementById('page-jump');\nfunction _doJump() {\n let v = parseInt(_jumpEl.value, 10);\n const max = +_jumpEl.max || 1;\n if (!Number.isFinite(v)) v = 1;\n v = Math.max(1, Math.min(max, v));\n state.page = v;\n applyFilter();\n}\n_jumpEl.addEventListener('change', _doJump);\n_jumpEl.addEventListener('blur', _doJump);\n_jumpEl.addEventListener('keydown', e => {\n if (e.key === 'Enter') { e.preventDefault(); _doJump(); _jumpEl.blur(); }\n});\n\napplyFilter();\n\n// ============== 上传资产 Modal ==============\n// 修复:把 modal 提到 <body> 末尾,脱离 main 的 stacking context 干扰\n// (main 是 position:relative + overflow:hidden, topbar z-index:2 会盖在 fixed modal-bg 之上)\nconst _modalRoot = document.getElementById('upload-modal-bg');\nif (_modalRoot && _modalRoot.parentElement !== document.body) {\n document.body.appendChild(_modalRoot);\n}\n\nconst uploadState = { kind: 'people', file: null, dataUrl: '', mime: '' };\n\nconst KIND_LABEL = { people: '人物', scenes: '场景', products: '商品图', finals: '成片' };\nconst DEFAULT_THUMB_TEXT = {\n people: '新资产', scenes: '新场景', products: '新商品图', finals: '9:16 · 新成片'\n};\nconst FILE_HINT = {\n image: '// JPG / PNG / WEBP · 单文件',\n video: '// MP4 / WEBM · 9:16 · ≤ 60 秒',\n};\n\nconst $ = (id) => document.getElementById(id);\nconst modalBg = $('upload-modal-bg');\nconst kindSel = $('upload-kind');\nconst zone = $('upload-zone');\nconst zoneText = $('upload-zone-text');\nconst zoneHint = $('upload-zone-hint');\nconst fileInput = $('upload-file');\nconst preview = $('upload-preview');\nconst previewImg = $('upload-preview-img');\nconst previewVideo = $('upload-preview-video');\nconst previewX = $('upload-preview-x');\nconst nameInput = $('upload-name');\nconst submitBtn = $('upload-submit');\n\nfunction syncKindFields() {\n document.querySelectorAll('.upload-modal .field[data-fields]').forEach(f => {\n f.hidden = f.dataset.fields !== uploadState.kind;\n });\n const isVideo = uploadState.kind === 'finals';\n fileInput.accept = isVideo ? 'video/*' : 'image/*';\n zoneText.textContent = isVideo ? '点击或拖拽上传视频' : '点击或拖拽上传图片';\n zoneHint.textContent = isVideo ? FILE_HINT.video : FILE_HINT.image;\n preview.classList.toggle('video', isVideo);\n}\n\nfunction syncSubmit() {\n let ok = !!uploadState.file && nameInput.value.trim().length > 0;\n if (ok && uploadState.kind === 'scenes' && !$('upload-scene-type').value.trim()) ok = false;\n if (ok && uploadState.kind === 'products' && !$('upload-product').value) ok = false;\n submitBtn.disabled = !ok;\n}\n\nfunction resetUploadModal() {\n uploadState.file = null;\n uploadState.dataUrl = '';\n uploadState.mime = '';\n fileInput.value = '';\n preview.hidden = true;\n previewImg.removeAttribute('src');\n previewVideo.removeAttribute('src');\n previewVideo.hidden = true;\n previewImg.hidden = false;\n zone.hidden = false;\n nameInput.value = '';\n $('upload-role').value = '';\n $('upload-scene-type').value = '';\n $('upload-product').selectedIndex = 0;\n $('upload-project').value = '';\n $('upload-gender').selectedIndex = 0;\n $('upload-age').value = '青年';\n $('upload-duration').value = '60s';\n syncSubmit();\n}\n\nfunction handleFile(file) {\n if (!file) return;\n const isVideo = uploadState.kind === 'finals';\n if (isVideo && !file.type.startsWith('video/')) {\n Shell.toast('请上传视频文件', file.type || 'unknown');\n return;\n }\n if (!isVideo && !file.type.startsWith('image/')) {\n Shell.toast('请上传图片文件', file.type || 'unknown');\n return;\n }\n uploadState.file = file;\n uploadState.mime = file.type;\n const reader = new FileReader();\n reader.onload = (e) => {\n uploadState.dataUrl = e.target.result;\n if (isVideo) {\n previewVideo.src = uploadState.dataUrl;\n previewVideo.hidden = false;\n previewImg.hidden = true;\n } else {\n previewImg.src = uploadState.dataUrl;\n previewImg.hidden = false;\n previewVideo.hidden = true;\n }\n preview.hidden = false;\n zone.hidden = true;\n // 自动填名称(去后缀)\n if (!nameInput.value) nameInput.value = file.name.replace(/\\.[^.]+$/, '');\n syncSubmit();\n };\n reader.readAsDataURL(file);\n}\n\nkindSel.addEventListener('change', () => {\n uploadState.kind = kindSel.value;\n resetUploadModal();\n syncKindFields();\n});\n\nzone.addEventListener('click', () => fileInput.click());\nzone.addEventListener('dragover', e => { e.preventDefault(); zone.style.borderColor = 'var(--heat)'; });\nzone.addEventListener('dragleave', () => { zone.style.borderColor = ''; });\nzone.addEventListener('drop', e => {\n e.preventDefault();\n zone.style.borderColor = '';\n if (e.dataTransfer?.files?.length) handleFile(e.dataTransfer.files[0]);\n});\nfileInput.addEventListener('change', e => {\n if (e.target.files?.length) handleFile(e.target.files[0]);\n});\n\npreviewX.addEventListener('click', () => {\n uploadState.file = null;\n uploadState.dataUrl = '';\n fileInput.value = '';\n preview.hidden = true;\n zone.hidden = false;\n previewImg.removeAttribute('src');\n previewVideo.removeAttribute('src');\n syncSubmit();\n});\n\nnameInput.addEventListener('input', syncSubmit);\n$('upload-scene-type').addEventListener('input', syncSubmit);\n$('upload-product').addEventListener('change', syncSubmit);\n\n// 提交 → 构造卡片插入到对应 grid 首位\nsubmitBtn.addEventListener('click', () => {\n if (submitBtn.disabled) return;\n const kind = uploadState.kind;\n const name = nameInput.value.trim();\n const today = new Date();\n const stamp = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, '0')}${String(today.getDate()).padStart(2, '0')}`;\n\n const card = document.createElement('div');\n card.className = 'asset-card' + (kind === 'finals' ? ' video' : '');\n card.dataset.name = name;\n card.dataset.source = '手动上传';\n card.dataset.used = '0';\n card.dataset.added = stamp;\n card.setAttribute('onclick', `Shell.toast('查看资产', ${JSON.stringify(name)})`);\n\n let metaText = '';\n if (kind === 'people') {\n const gender = $('upload-gender').value;\n const age = $('upload-age').value;\n const role = $('upload-role').value.trim() || '—';\n card.dataset.gender = gender;\n card.dataset.age = age;\n card.dataset.role = role;\n metaText = `${gender} · ${age} · ${role} · 手动上传`;\n } else if (kind === 'scenes') {\n const sceneType = $('upload-scene-type').value.trim();\n card.dataset.sceneType = sceneType;\n metaText = `${sceneType} · 手动上传 · 用过 0 次`;\n } else if (kind === 'products') {\n const product = $('upload-product').value;\n card.dataset.product = product;\n metaText = `${product} · 手动上传 · 用过 0 次`;\n } else if (kind === 'finals') {\n const project = $('upload-project').value.trim() || name;\n const duration = $('upload-duration').value;\n card.dataset.project = project;\n card.dataset.duration = duration;\n metaText = `${project} · ${duration} · 手动上传`;\n }\n\n // 缩略图:有 dataUrl 用真实预览,否则占位\n const thumb = document.createElement('div');\n thumb.className = 'placeholder asset-thumb';\n if (uploadState.dataUrl) {\n if (kind === 'finals') {\n thumb.innerHTML = `<video src=\"${uploadState.dataUrl}\" muted playsinline style=\"width:100%;height:100%;object-fit:cover;display:block;border-radius:inherit;\"></video>`;\n } else {\n thumb.style.backgroundImage = `url(\"${uploadState.dataUrl}\")`;\n thumb.style.backgroundSize = 'cover';\n thumb.style.backgroundPosition = 'center';\n thumb.innerHTML = '';\n }\n } else {\n thumb.innerHTML = `<span class=\"ph-frame\">${DEFAULT_THUMB_TEXT[kind]}</span>`;\n }\n\n const body = document.createElement('div');\n body.className = 'asset-body';\n body.innerHTML = `<div class=\"asset-name\">${name}</div><div class=\"asset-meta\">${metaText}</div>`;\n card.appendChild(thumb);\n card.appendChild(body);\n\n // 插入 grid 首位 + 更新 cardsByTab + 计数 + 切到该 tab\n const targetGrid = $(`grid-${kind}`);\n targetGrid.prepend(card);\n cardsByTab[kind].unshift(card);\n\n // 刷新 tab count + 副标题计数\n const tabCount = document.querySelector(`.tab[data-tab=\"${kind}\"] .count`);\n if (tabCount) tabCount.textContent = cardsByTab[kind].length;\n if (kind === 'people') $('sub-people').textContent = cardsByTab.people.length;\n if (kind === 'scenes') $('sub-scenes').textContent = cardsByTab.scenes.length;\n if (kind === 'products') $('sub-products').textContent = cardsByTab.products.length;\n if (kind === 'finals') $('sub-finals').textContent = cardsByTab.finals.length;\n if (sidebarBadge) {\n const total = TAB_KEYS.reduce((s, t) => s + cardsByTab[t].length, 0);\n sidebarBadge.textContent = total;\n }\n\n // 角色标签来源新值 → 重建角色菜单(可能新增)\n if (kind === 'people') {\n buildMenu('role', uniqueValues('people', 'role').map(v => ({ value: v, label: v })), '全部角色');\n syncChipUI('role');\n }\n if (kind === 'scenes') {\n buildMenu('sceneType', uniqueValues('scenes', 'sceneType').map(v => ({ value: v, label: v })), '全部场景');\n syncChipUI('sceneType');\n }\n if (kind === 'products') {\n buildMenu('product', uniqueValues('products', 'product').map(v => ({ value: v, label: v })), '全部商品');\n syncChipUI('product');\n }\n if (kind === 'finals') {\n buildMenu('project', uniqueValues('finals', 'project').map(v => ({ value: v, label: v })), '全部项目');\n syncChipUI('project');\n }\n\n // 切到目标 tab + 清空筛选\n document.querySelectorAll('#asset-tabs .tab').forEach(t =>\n t.classList.toggle('active', t.dataset.tab === kind)\n );\n state.tab = kind;\n state.search = ''; $('search-input').value = '';\n ['gender', 'age', 'role', 'sceneType', 'product', 'project', 'duration', 'source'].forEach(k => {\n state[k] = 'all';\n syncChipUI(k);\n });\n state.source = 'all';\n state.page = 1;\n rebuildSourceMenu();\n applyFilter();\n\n Shell.closeModal('upload-modal-bg');\n Shell.toast(`已上传到${KIND_LABEL[kind]}`, `+ ${name}`);\n});\n\n// 打开按钮\n$('open-upload-btn').addEventListener('click', () => {\n resetUploadModal();\n kindSel.value = state.tab in KIND_LABEL ? state.tab : 'people';\n uploadState.kind = kindSel.value;\n syncKindFields();\n Shell.openModal('upload-modal-bg');\n setTimeout(() => nameInput.focus(), 100);\n});\n\n// ESC 关闭\ndocument.addEventListener('keydown', e => {\n if (e.key === 'Escape' && modalBg.classList.contains('show')) {\n Shell.closeModal('upload-modal-bg');\n }\n});\n\n// 初始化\nsyncKindFields();\n\n// ============================================================\n// 资产删除 + 批量管理 (PRD §6.3 软删除/引用检查)\n// ============================================================\n// 模拟引用记录: 某些资产被项目引用 (实际从后台获取)\nconst ASSET_REFS = {\n '林夕': ['夏日水嫩计划', '春装新品'],\n '小七': ['学生季推广'],\n '卧室·暖光': ['补水面膜 v1', '面膜对比'],\n '客厅·北欧': ['咖啡早八剧情'],\n '蓝牙耳机 · 开箱测评': ['南卡 Lite 推广']\n};\nfunction getAssetRefs(card) {\n const name = card.dataset.name || '';\n return ASSET_REFS[name] || [];\n}\n\nconst delBg = document.getElementById('del-confirm-bg');\nconst delBody = document.getElementById('del-confirm-body');\nconst delFoot = document.getElementById('del-confirm-foot');\nconst delCancel = document.getElementById('del-confirm-cancel');\nconst delOk = document.getElementById('del-confirm-ok');\nlet _delQueue = [];\n\nfunction setFootDeletable() {\n delFoot.innerHTML = '';\n delFoot.appendChild(delCancel);\n delFoot.appendChild(delOk);\n}\nfunction setFootBlocked() {\n delFoot.innerHTML = '';\n const okBtn = document.createElement('button');\n okBtn.className = 'btn btn-primary';\n okBtn.type = 'button';\n okBtn.textContent = '我知道了';\n okBtn.addEventListener('click', closeDelConfirm);\n delFoot.appendChild(okBtn);\n}\nfunction openDelConfirm(targets) {\n const blocked = targets.filter(c => getAssetRefs(c).length > 0);\n const deletable = targets.filter(c => getAssetRefs(c).length === 0);\n if (deletable.length === 0 && blocked.length > 0) {\n const c = blocked[0];\n const refs = getAssetRefs(c);\n if (blocked.length === 1) {\n delBody.innerHTML = `<span class=\"mono-acc\">${c.dataset.name}</span> 当前被 <b>${refs.length}</b> 个项目使用,无法直接删除。请先在以下项目中解除引用:<br><br>` +\n refs.map(r => `<span class=\"mono-acc\" style=\"margin-right:6px\">${r}</span>`).join('');\n } else {\n delBody.innerHTML = `所选 <b>${blocked.length}</b> 个资产均被项目引用,无法直接删除。`;\n }\n setFootBlocked();\n delBg.classList.add('show');\n _delQueue = [];\n return;\n }\n _delQueue = deletable;\n if (deletable.length === 1 && blocked.length === 0) {\n delBody.innerHTML = '即将删除 <span class=\"mono-acc\">' + deletable[0].dataset.name + '</span>,此操作无法撤销。';\n } else if (blocked.length > 0) {\n delBody.innerHTML = '即将删除 <span class=\"mono-acc\">' + deletable.length + ' 个资产</span>,其中 <b>' + blocked.length + '</b> 个被项目引用已跳过。';\n } else {\n delBody.innerHTML = '即将删除 <span class=\"mono-acc\">' + deletable.length + ' 个资产</span>,此操作无法撤销。';\n }\n setFootDeletable();\n delBg.classList.add('show');\n}\nfunction closeDelConfirm() { delBg.classList.remove('show'); _delQueue = []; }\ndelCancel.addEventListener('click', closeDelConfirm);\ndelBg.addEventListener('click', e => { if (e.target === delBg) closeDelConfirm(); });\ndelOk.addEventListener('click', () => {\n const n = _delQueue.length;\n // 收集被删除中、source 是\"未分类\"的 libId,同步从 localStorage 移除\n const removedLibIds = _delQueue\n .filter(c => c.dataset.libId)\n .map(c => c.dataset.libId);\n _delQueue.forEach(card => card.remove());\n if (removedLibIds.length) {\n try {\n const LIB_KEY = 'fs-library-unclassified';\n const list = JSON.parse(localStorage.getItem(LIB_KEY) || '[]');\n const next = (Array.isArray(list) ? list : []).filter(x => !removedLibIds.includes(x.id));\n localStorage.setItem(LIB_KEY, JSON.stringify(next));\n } catch (e) { /* ignore */ }\n }\n closeDelConfirm();\n Shell.toast('已删除', n === 1 ? '资产已移除' : '已删除 ' + n + ' 个资产');\n updateBulkBar();\n});\n\n// 单卡片删除按钮\ndocument.querySelectorAll('.asset-card .card-del-btn').forEach(btn => {\n btn.addEventListener('click', e => {\n e.stopPropagation();\n const card = btn.closest('.asset-card');\n if (!card) return;\n openDelConfirm([card]);\n });\n});\n\n// 管理资产模式\nconst libManageBtn = document.getElementById('lib-manage-btn');\nconst libManageLabel = libManageBtn.querySelector('.lib-manage-label');\nconst bulkBar = document.getElementById('bulk-bar');\nconst bulkCount = document.getElementById('bulk-count');\nconst bulkClear = document.getElementById('bulk-clear');\nconst bulkDel = document.getElementById('bulk-del');\nconst bulkExit = document.getElementById('bulk-exit');\n\nfunction getSelected() { return [...document.querySelectorAll('.asset-card.selected')]; }\nfunction updateBulkBar() {\n const sel = getSelected();\n bulkCount.textContent = sel.length;\n bulkDel.disabled = sel.length === 0;\n bulkDel.style.opacity = sel.length === 0 ? '.4' : '1';\n}\nfunction enterEditMode() {\n document.body.classList.add('edit-mode');\n libManageBtn.classList.add('active');\n libManageLabel.textContent = '完成';\n applyDraggableToCards(true);\n updateBulkBar();\n}\nfunction exitEditMode() {\n document.body.classList.remove('edit-mode');\n libManageBtn.classList.remove('active');\n libManageLabel.textContent = '管理资产';\n document.querySelectorAll('.asset-card.selected').forEach(c => c.classList.remove('selected'));\n applyDraggableToCards(false);\n if (bulkMoveMenu) bulkMoveMenu.classList.remove('show');\n}\nlibManageBtn.addEventListener('click', () => {\n if (document.body.classList.contains('edit-mode')) exitEditMode();\n else enterEditMode();\n});\nbulkExit.addEventListener('click', exitEditMode);\nbulkClear.addEventListener('click', () => {\n document.querySelectorAll('.asset-card.selected').forEach(c => c.classList.remove('selected'));\n updateBulkBar();\n});\nbulkDel.addEventListener('click', () => {\n const sel = getSelected();\n if (!sel.length) return;\n openDelConfirm(sel);\n});\n\n// ── 移动到 · 菜单 + 拖拽 ──\nconst TAB_NAMES = {\n people: '人物', scenes: '场景', products: '商品图',\n finals: '成片', uploads: '我的上传', unclassified: '未分类'\n};\nconst bulkMove = document.getElementById('bulk-move');\nconst bulkMoveMenu = document.getElementById('bulk-move-menu');\n\nfunction getCurrentTab() {\n const active = document.querySelector('#asset-tabs .tab.active');\n return active ? active.dataset.tab : TAB_KEYS[0];\n}\nfunction refreshAllTabCounts() {\n TAB_KEYS.forEach(t => {\n const tab = document.querySelector(`.tab[data-tab=\"${t}\"] .count`);\n if (tab && cardsByTab[t]) tab.textContent = cardsByTab[t].length;\n });\n const total = TAB_KEYS.reduce((s, t) => s + (cardsByTab[t] ? cardsByTab[t].length : 0), 0);\n const sidebarBadge = document.querySelector('aside.sidebar a[href=\"library.html\"] .pill-mini');\n if (sidebarBadge) sidebarBadge.textContent = total;\n const subMap = { people:'sub-people', scenes:'sub-scenes', products:'sub-products', finals:'sub-finals' };\n Object.keys(subMap).forEach(k => {\n const el = document.getElementById(subMap[k]);\n if (el && cardsByTab[k]) el.textContent = cardsByTab[k].length;\n });\n}\nfunction moveSelectedTo(targetTab) {\n const sel = getSelected();\n if (!sel.length) { Shell.toast('请先选中资产'); return; }\n const targetGrid = document.getElementById(`grid-${targetTab}`);\n if (!targetGrid) return;\n let movedCount = 0;\n sel.forEach(card => {\n // 找出 card 当前所在 tab (DOM-based,跨多 tab 的 selected 也能正确移动)\n const curGrid = card.closest('.asset-grid');\n const curTab = curGrid ? curGrid.dataset.tab : getCurrentTab();\n if (curTab === targetTab) return; // 同分类跳过\n // 更新内存\n if (cardsByTab[curTab]) {\n const idx = cardsByTab[curTab].indexOf(card);\n if (idx >= 0) cardsByTab[curTab].splice(idx, 1);\n }\n if (cardsByTab[targetTab]) cardsByTab[targetTab].push(card);\n // 更新 DOM\n card.classList.remove('selected');\n card.dataset.kind = TAB_NAMES[targetTab];\n targetGrid.appendChild(card);\n movedCount += 1;\n });\n refreshAllTabCounts();\n updateBulkBar();\n if (movedCount > 0) {\n Shell.toast('已移动', `${movedCount} 个资产 → 「${TAB_NAMES[targetTab]}」`);\n } else {\n Shell.toast('未移动', '所选资产已在该分类');\n }\n}\nfunction renderMoveMenu() {\n const cur = getCurrentTab();\n bulkMoveMenu.innerHTML = TAB_KEYS\n .filter(t => t !== cur)\n .map(t => `<button class=\"mv-item\" type=\"button\" data-target=\"${t}\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"9 18 15 12 9 6\"/></svg>\n 移到「${TAB_NAMES[t]}」\n </button>`).join('');\n bulkMoveMenu.querySelectorAll('.mv-item').forEach(btn => {\n btn.addEventListener('click', () => {\n moveSelectedTo(btn.dataset.target);\n bulkMoveMenu.classList.remove('show');\n });\n });\n}\nbulkMove.addEventListener('click', e => {\n e.stopPropagation();\n if (!getSelected().length) { Shell.toast('请先选中资产'); return; }\n renderMoveMenu();\n bulkMoveMenu.classList.toggle('show');\n});\ndocument.addEventListener('click', e => {\n if (!bulkMove.contains(e.target) && !bulkMoveMenu.contains(e.target)) {\n bulkMoveMenu.classList.remove('show');\n }\n});\n\n// ── 拖拽到 tab 移动 (edit-mode 下生效) ──\nfunction applyDraggableToCards(on) {\n document.querySelectorAll('.asset-card').forEach(c => {\n if (on) c.setAttribute('draggable', 'true');\n else c.removeAttribute('draggable');\n });\n}\ndocument.addEventListener('dragstart', e => {\n const card = e.target.closest('.asset-card');\n if (!card || !document.body.classList.contains('edit-mode')) return;\n // 没选中就当前 card 也算 (允许直接拖单张)\n if (!card.classList.contains('selected')) {\n card.classList.add('selected');\n updateBulkBar();\n }\n card.classList.add('dragging');\n e.dataTransfer.effectAllowed = 'move';\n try { e.dataTransfer.setData('text/plain', 'move-asset'); } catch (err) {}\n});\ndocument.addEventListener('dragend', e => {\n const card = e.target.closest('.asset-card');\n if (card) card.classList.remove('dragging');\n document.querySelectorAll('#asset-tabs .tab.drag-over').forEach(t => t.classList.remove('drag-over'));\n});\ndocument.querySelectorAll('#asset-tabs .tab').forEach(tab => {\n tab.addEventListener('dragover', e => {\n if (!document.body.classList.contains('edit-mode')) return;\n if (tab.dataset.tab === getCurrentTab()) return;\n e.preventDefault();\n e.dataTransfer.dropEffect = 'move';\n tab.classList.add('drag-over');\n });\n tab.addEventListener('dragleave', () => tab.classList.remove('drag-over'));\n tab.addEventListener('drop', e => {\n if (!document.body.classList.contains('edit-mode')) return;\n e.preventDefault();\n tab.classList.remove('drag-over');\n if (tab.dataset.tab === getCurrentTab()) return;\n moveSelectedTo(tab.dataset.tab);\n });\n});\n\n// 编辑模式下,卡片点击切换 selected (不再 toast / 打开)\ndocument.querySelectorAll('.asset-card').forEach(card => {\n card.addEventListener('click', e => {\n if (!document.body.classList.contains('edit-mode')) return;\n e.stopImmediatePropagation();\n e.preventDefault();\n card.classList.toggle('selected');\n updateBulkBar();\n }, true);\n});\n\n/* ============================================================\n 资产详情 modal · 与 pipeline.html 共用参考布局 v2\n ============================================================ */\n(function () {\n // 注入 modal CSS (与 pipeline.html 保持一致的 .asset-* 命名)\n const css = `\n .asset-modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1000; display: none; align-items: center; justify-content: center; padding: 40px; }\n .asset-modal-bg.show { display: flex; }\n .asset-modal { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); width: min(1040px, 100%); max-height: calc(100vh - 80px); overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 16px 48px rgba(0,0,0,.18); }\n .asset-modal-h { display: flex; align-items: center; gap: 10px; padding: 14px 20px; border-bottom: 1px solid var(--border-faint); }\n .asset-modal-h h2 { font-size: 15px; font-weight: 600; }\n .asset-modal-h .ad-tag { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .asset-modal-h .x { width: 30px; height: 30px; display: grid; place-items: center; background: transparent; border: 0; cursor: pointer; color: var(--black-alpha-56); border-radius: var(--r-sm); margin-left: auto; }\n .asset-modal-h .x:hover { background: var(--black-alpha-8); color: var(--accent-black); }\n .asset-modal-body { padding: 20px 24px 24px; overflow-y: auto; flex: 1; }\n .asset-detail-grid { display: grid; grid-template-columns: 340px 1fr; gap: 24px; }\n .asset-detail-lead { display: flex; flex-direction: column; gap: 10px; }\n .asset-detail-lead .ad-lead-wrap { position: relative; }\n .asset-detail-lead .placeholder.ad-lead-img { aspect-ratio: 3/4; border-radius: var(--r-md); }\n /* 查看大图 icon · 悬浮容器才显示 · 32×32 icon-only */\n .ad-zoom-btn { position: absolute; right: 8px; bottom: 8px; width: 32px; height: 32px; padding: 0; background: rgba(21,20,15,.7); color: #fff; border: 0; border-radius: var(--r-sm); display: grid; place-items: center; cursor: pointer; backdrop-filter: blur(4px); opacity: 0; transition: opacity var(--t-base), background var(--t-base); z-index: 3; }\n .ad-zoom-btn:hover { background: rgba(21,20,15,.92); }\n .ad-zoom-btn svg { width: 14px; height: 14px; }\n .asset-detail-lead .ad-lead-wrap:hover .ad-zoom-btn,\n .asset-detail-tri-row .placeholder:hover .ad-zoom-btn { opacity: 1; }\n .asset-detail-tri-row .placeholder { position: relative; }\n .asset-detail-lead .ad-thumbs { display: flex; gap: 8px; }\n .asset-detail-lead .ad-thumbs .thumb { flex: 0 0 64px; aspect-ratio: 3/4; border-radius: var(--r-sm); border: 1px solid var(--border-faint); cursor: pointer; overflow: hidden; }\n .asset-detail-lead .ad-thumbs .thumb:hover { border-color: var(--heat-40); }\n .asset-detail-lead .ad-thumbs .thumb.active { border-color: var(--heat); border-width: 2px; }\n .asset-detail-right .ad-section + .ad-section { margin-top: 18px; }\n .asset-detail-section-h { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; color: var(--accent-black); margin-bottom: 10px; }\n .asset-detail-section-h .ic { width: 14px; height: 14px; color: var(--heat); display: grid; place-items: center; }\n .asset-detail-section-h .ic svg { width: 14px; height: 14px; }\n .asset-detail-section-h .ad-ratio-chip { margin-left: auto; font-family: var(--font-mono); font-size: 10.5px; padding: 2px 8px; border-radius: var(--r-sm); background: var(--background-lighter); border: 1px solid var(--border-faint); color: var(--black-alpha-56); }\n .asset-detail-section-h .ad-icon-btn { width: 28px; height: 28px; display: grid; place-items: center; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-56); cursor: pointer; }\n .asset-detail-section-h .ad-icon-btn:hover { color: var(--heat); border-color: var(--heat-40); }\n .asset-detail-section-h .ad-icon-btn svg { width: 12px; height: 12px; }\n .asset-detail-tri-row .placeholder { aspect-ratio: 16/9; border-radius: var(--r-md); }\n .asset-detail-tri-row .placeholder.missing { display: grid; place-items: center; border: 1px dashed var(--border-faint); background: var(--background-lighter); color: var(--black-alpha-48); font-family: var(--font-mono); font-size: 12px; padding: 12px; text-align: center; cursor: pointer; gap: 8px; }\n .asset-detail-tri-row .placeholder.missing:hover { border-color: var(--heat); color: var(--heat); }\n .ad-intro { font-size: 13px; line-height: 1.65; color: var(--black-alpha-72); margin: 0 0 12px; }\n .ad-tags { display: flex; flex-wrap: wrap; gap: 8px; }\n .ad-tags .ad-tag-chip { height: 26px; padding: 0 12px; display: inline-flex; align-items: center; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm); font-size: 12px; color: var(--accent-black); }\n .ad-tags .ad-tag-add { width: 26px; height: 26px; display: grid; place-items: center; background: var(--background-lighter); border: 1px dashed var(--black-alpha-24); border-radius: var(--r-sm); color: var(--black-alpha-56); cursor: pointer; }\n .ad-tags .ad-tag-add:hover { border-color: var(--heat); color: var(--heat); }\n .ad-tags .ad-tag-add svg { width: 12px; height: 12px; }\n .ad-props { margin-top: 18px; display: grid; grid-template-columns: repeat(3, 1fr); column-gap: 24px; row-gap: 0; border-top: 1px solid var(--border-faint); padding-top: 16px; }\n .ad-props .ad-prop { display: flex; align-items: baseline; padding: 10px 0; border-bottom: 1px solid var(--border-faint); font-size: 12.5px; min-height: 38px; }\n .ad-props .ad-prop:nth-last-child(-n+3) { border-bottom: 0; }\n .ad-props .ad-prop .k { flex: 0 0 64px; color: var(--black-alpha-56); font-family: var(--font-mono); font-size: 11px; }\n .ad-props .ad-prop .v { color: var(--accent-black); font-weight: 500; word-break: break-all; }\n .asset-detail-tip { margin-top: 10px; padding: 10px 12px; background: var(--heat-12); border: 1px solid var(--heat-20); border-radius: var(--r-sm); font-size: 12px; color: var(--accent-black); display: flex; align-items: center; gap: 8px; line-height: 1.5; }\n .asset-detail-tip svg { width: 14px; height: 14px; color: var(--heat); flex-shrink: 0; }\n .asset-detail-tip .ai-gen-btn { margin-left: auto; height: 26px; padding: 0 10px; background: var(--heat); color: #fff; border: 1px solid var(--heat); border-radius: var(--r-sm); font-size: 11.5px; cursor: pointer; font-family: inherit; flex-shrink: 0; display: inline-flex; align-items: center; }\n .asset-detail-tip .ai-gen-btn:disabled { opacity: .55; cursor: not-allowed; }\n .asset-detail-tip.is-loading svg { animation: ad-spin 1s linear infinite; }\n @keyframes ad-spin { to { transform: rotate(360deg); } }\n .asset-detail-history { margin-top: 10px; padding: 10px 12px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm); }\n .asset-detail-history .adh-h { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-56); margin-bottom: 8px; letter-spacing: .02em; }\n .asset-detail-history .adh-h .adh-cur { color: var(--heat); }\n .asset-detail-history .adh-row { display: flex; gap: 8px; flex-wrap: wrap; }\n .asset-detail-history .adh-thumb { width: 64px; height: 36px; border-radius: var(--r-sm); background: var(--background-base); border: 1px solid var(--border-faint); display: grid; place-items: center; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-72); cursor: pointer; position: relative; transition: border-color var(--t-base), color var(--t-base); }\n .asset-detail-history .adh-thumb:hover { border-color: var(--heat-40); color: var(--heat); }\n .asset-detail-history .adh-thumb.active { border-color: var(--heat); color: var(--heat); background: var(--heat-12); }\n .asset-detail-history .adh-thumb.active::after { content: \"\"; position: absolute; top: -3px; right: -3px; width: 8px; height: 8px; background: var(--heat); border: 2px solid var(--surface); border-radius: 50%; }\n .asset-modal-f { padding: 14px 20px; border-top: 1px solid var(--border-faint); display: flex; align-items: center; gap: 8px; }\n .asset-modal-f .ad-foot-stats { display: flex; gap: 6px; margin-right: auto; }\n .asset-modal-f .ad-stat-btn { height: 32px; padding: 0 12px; display: inline-flex; align-items: center; gap: 6px; background: transparent; border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-72); font-size: 12.5px; font-family: inherit; cursor: pointer; }\n .asset-modal-f .ad-stat-btn:hover { background: var(--background-lighter); color: var(--accent-black); border-color: var(--black-alpha-24); }\n .asset-modal-f .ad-stat-btn svg { width: 13px; height: 13px; }\n .asset-modal-f .ad-stat-btn b { color: var(--accent-black); font-weight: 600; }\n /* ── 缺保存 · 二次确认弹窗(模仿 model-photo .mc-leave) ── */\n .lib-confirm-bg { position: fixed; inset: 0; z-index: 1300; background: rgba(21,20,15,.42); display: none; align-items: center; justify-content: center; padding: 40px; }\n .lib-confirm-bg.show { display: flex; }\n .lib-confirm { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); width: 440px; max-width: 92vw; box-shadow: 0 24px 64px rgba(0,0,0,.16); overflow: hidden; }\n .lib-confirm .lc-h { display: flex; align-items: center; gap: 10px; padding: 14px 20px 10px; }\n .lib-confirm .lc-h .ic { width: 28px; height: 28px; display: grid; place-items: center; background: var(--heat-12); color: var(--heat); border-radius: var(--r-sm); flex-shrink: 0; }\n .lib-confirm .lc-h .ic svg { width: 16px; height: 16px; }\n .lib-confirm .lc-h h3 { font-size: 15px; font-weight: 600; color: var(--accent-black); margin: 0; }\n .lib-confirm .lc-h .mono { margin-left: auto; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; }\n .lib-confirm .lc-b { padding: 4px 20px 18px; font-size: 13px; line-height: 1.65; color: var(--black-alpha-72); }\n .lib-confirm .lc-b b { color: var(--accent-black); font-weight: 600; }\n .lib-confirm .lc-f { display: flex; align-items: center; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--border-faint); background: var(--background-lighter); }\n .lib-confirm .lc-f .spacer { flex: 1; }\n .lib-confirm .lc-f .btn { height: 34px; padding: 0 14px; font-size: 13px; }\n `;\n const style = document.createElement('style');\n style.textContent = css;\n document.head.appendChild(style);\n\n const modalHTML = `\n <div class=\"asset-modal-bg\" id=\"lib-detail-bg\">\n <div class=\"asset-modal\">\n <div class=\"asset-modal-h\">\n <h2 id=\"lib-detail-title\">资产详情</h2>\n <span class=\"ad-tag\" id=\"lib-detail-kind\">/ kind</span>\n <button class=\"x\" id=\"lib-detail-x\" type=\"button\" aria-label=\"关闭\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6 6l12 12M6 18L18 6\"/></svg></button>\n </div>\n <div class=\"asset-modal-body\">\n <div class=\"asset-detail-grid\">\n <div class=\"asset-detail-lead\">\n <div class=\"ad-lead-wrap\">\n <div class=\"placeholder ad-lead-img\" id=\"lib-detail-lead-img\"><span class=\"ph-frame\">立绘 / 主图</span></div>\n <button class=\"ad-zoom-btn\" type=\"button\" id=\"lib-detail-lead-zoom\" aria-label=\"查看大图\" title=\"查看大图\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5\"/></svg>\n </button>\n </div>\n <div class=\"ad-thumbs\" id=\"lib-detail-thumbs\"></div>\n </div>\n <div class=\"asset-detail-right\">\n <div class=\"ad-section\" id=\"lib-detail-tri-section\">\n <div class=\"asset-detail-section-h\">\n <span class=\"ic\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"7\" height=\"7\"/><rect x=\"14\" y=\"3\" width=\"7\" height=\"7\"/><rect x=\"3\" y=\"14\" width=\"7\" height=\"7\"/></svg></span>\n <span class=\"t\">三视图</span>\n <span class=\"ad-ratio-chip\" id=\"lib-detail-ratio\">16:9</span>\n <button class=\"ad-icon-btn\" type=\"button\" title=\"下载\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3\"/></svg></button>\n </div>\n <div class=\"asset-detail-tri-row\" id=\"lib-detail-tri\">\n <div class=\"placeholder\"><span class=\"ph-frame\">正 / 侧 / 背 · 三视图</span></div>\n </div>\n <div class=\"asset-detail-tip\" id=\"lib-detail-tip\" style=\"display:none;\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 8v4M12 16h.01\"/></svg>\n <span id=\"lib-detail-tip-text\">暂无三视图,建议用 AI 生成以保证多角度一致性</span>\n <button class=\"ai-gen-btn\" type=\"button\" id=\"lib-detail-aigen\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" style=\"width:11px;height:11px;display:inline-block;vertical-align:-1px;margin-right:3px;\"><path d=\"M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8L12 3z\"/></svg>\n <span id=\"lib-detail-aigen-label\">AI 生成三视图</span>\n </button>\n </div>\n <div class=\"asset-detail-history\" id=\"lib-detail-history\" style=\"display:none;\">\n <div class=\"adh-h\">// 三视图版本 · <span class=\"adh-ct\" id=\"lib-detail-history-count\">0</span> 版 · <span class=\"adh-cur\" id=\"lib-detail-history-cur\">v1</span></div>\n <div class=\"adh-row\" id=\"lib-detail-history-row\"></div>\n </div>\n </div>\n <div class=\"ad-section\">\n <div class=\"asset-detail-section-h\">\n <span class=\"ic\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 6h16M4 12h16M4 18h10\"/></svg></span>\n <span class=\"t\">简介</span>\n </div>\n <p class=\"ad-intro\" id=\"lib-detail-intro\"></p>\n <div class=\"ad-tags\" id=\"lib-detail-tags\"></div>\n </div>\n <div class=\"ad-props\" id=\"lib-detail-props\"></div>\n </div>\n </div>\n </div>\n <div class=\"asset-modal-f\">\n <div class=\"ad-foot-stats\">\n <button class=\"ad-stat-btn\" type=\"button\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3\"/></svg>\n 下载\n </button>\n </div>\n <button class=\"btn btn-primary\" type=\"button\" id=\"lib-detail-apply\">保存</button>\n </div>\n </div>\n </div>\n <div class=\"lib-confirm-bg\" id=\"lib-confirm-bg\" aria-hidden=\"true\">\n <div class=\"lib-confirm\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"lib-confirm-title\">\n <div class=\"lc-h\">\n <span class=\"ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 9v4M12 17h.01M10.3 3.86l-8.18 14.18A2 2 0 0 0 3.84 21h16.32a2 2 0 0 0 1.72-2.96L13.7 3.86a2 2 0 0 0-3.4 0z\"/></svg>\n </span>\n <h3 id=\"lib-confirm-title\">三视图尚未保存</h3>\n <span class=\"mono\">// UNSAVED</span>\n </div>\n <div class=\"lc-b\" id=\"lib-confirm-body\">\n 已生成 <b id=\"lib-confirm-count\">1</b> 版三视图但<b>尚未保存</b>。直接退出会丢失这些版本,且当前资产仍标记为「<b>缺三视图</b>」。\n </div>\n <div class=\"lc-f\">\n <span class=\"spacer\"></span>\n <button class=\"btn\" type=\"button\" id=\"lib-confirm-discard\">退出</button>\n <button class=\"btn btn-primary\" type=\"button\" id=\"lib-confirm-save\">保存并退出</button>\n </div>\n </div>\n </div>`;\n document.body.insertAdjacentHTML('beforeend', modalHTML);\n\n const bg = document.getElementById('lib-detail-bg');\n const titleEl = document.getElementById('lib-detail-title');\n const kindEl = document.getElementById('lib-detail-kind');\n const leadImg = document.getElementById('lib-detail-lead-img');\n const thumbsEl = document.getElementById('lib-detail-thumbs');\n const triSection = document.getElementById('lib-detail-tri-section');\n const triEl = document.getElementById('lib-detail-tri');\n const ratioChip = document.getElementById('lib-detail-ratio');\n const introEl = document.getElementById('lib-detail-intro');\n const tagsEl = document.getElementById('lib-detail-tags');\n const propsEl = document.getElementById('lib-detail-props');\n const tipEl = document.getElementById('lib-detail-tip');\n const tipTextEl = document.getElementById('lib-detail-tip-text');\n const aigenBtn = document.getElementById('lib-detail-aigen');\n const aigenLabel = document.getElementById('lib-detail-aigen-label');\n const historyEl = document.getElementById('lib-detail-history');\n const historyRowEl = document.getElementById('lib-detail-history-row');\n const historyCountEl = document.getElementById('lib-detail-history-count');\n const historyCurEl = document.getElementById('lib-detail-history-cur');\n const applyBtn = document.getElementById('lib-detail-apply');\n\n // 当前打开资产的状态(仅 isActor + missing tri 时启用)\n let _curCard = null;\n let _curName = '';\n let _versions = []; // [{ ts, label }]\n let _curIdx = -1;\n let _dirty = false; // 已生成但未保存\n let _generating = false;\n let _allowGen = false; // 是否启用生成入口(missing tri-view 才启用)\n\n function _renderHistory() {\n if (!_versions.length) { historyEl.style.display = 'none'; return; }\n historyEl.style.display = 'block';\n historyCountEl.textContent = _versions.length;\n historyCurEl.textContent = _versions[_curIdx]?.label || '';\n historyRowEl.innerHTML = _versions.map((v, i) =>\n '<div class=\"adh-thumb' + (i === _curIdx ? ' active' : '') + '\" data-idx=\"' + i + '\" title=\"' + v.label + ' · ' + v.ts + '\">' + v.label + '</div>'\n ).join('');\n historyRowEl.querySelectorAll('.adh-thumb').forEach(el => {\n el.addEventListener('click', () => {\n const i = Number(el.dataset.idx);\n if (i === _curIdx) return;\n _curIdx = i;\n _renderTriPreview();\n _renderHistory();\n });\n });\n }\n\n function _renderTriPreview() {\n if (_curIdx < 0) return;\n const ver = _versions[_curIdx];\n triEl.innerHTML = '<div class=\"placeholder\"><span class=\"ph-frame\">' + _curName + ' · 三视图(正/侧/背)· ' + ver.label + '</span><button class=\"ad-zoom-btn\" type=\"button\" data-zoom-tri aria-label=\"查看大图\" title=\"查看大图\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5\"/></svg></button></div>';\n triEl.querySelector('[data-zoom-tri]')?.addEventListener('click', e => {\n e.stopPropagation();\n if (window.Shell?._openLightbox) Shell._openLightbox('', _curName + ' · 三视图 · ' + ver.label);\n });\n }\n\n function _renderTriLoading() {\n triEl.innerHTML = '<div class=\"placeholder\" style=\"display:grid;place-items:center;gap:6px;\"><div class=\"spinner\" style=\"width:22px;height:22px;border:2px solid var(--border-faint);border-top-color:var(--heat);border-radius:50%;animation:ad-spin 1s linear infinite;\"></div><span class=\"ph-frame\" style=\"font-size:10.5px;\">生成中 · 约 12s</span></div>';\n }\n\n function _setAigenLabel(text, loading) {\n aigenLabel.textContent = text;\n aigenBtn.disabled = !!loading;\n tipEl.classList.toggle('is-loading', !!loading);\n }\n\n function _startGenerate() {\n if (!_allowGen || _generating) return;\n _generating = true;\n _setAigenLabel(_versions.length ? '生成中…' : '生成中…', true);\n _renderTriLoading();\n setTimeout(() => {\n _generating = false;\n const now = new Date();\n const ts = String(now.getHours()).padStart(2,'0') + ':' + String(now.getMinutes()).padStart(2,'0');\n _versions.push({ ts, label: 'v' + (_versions.length + 1) });\n _curIdx = _versions.length - 1;\n _dirty = true;\n _renderTriPreview();\n _renderHistory();\n // 第一次生成后,按钮文案 → 再次生成;并隐藏 tip 文案,只留按钮在右侧\n tipEl.style.display = 'flex';\n tipTextEl.textContent = '已生成 ' + _versions.length + ' 版三视图 · 不满意可重跑,保存后写入资产';\n _setAigenLabel('再次生成', false);\n if (window.Shell?.toast) {\n Shell.toast('三视图已生成', _curName + ' · ' + _versions[_curIdx].label + ' · 满意请点「保存」');\n }\n }, 1500);\n }\n\n function _hash(s) { let h = 0; for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0; return Math.abs(h); }\n function _fmtAssetId(name, k) { return 'ASSET-20240520-' + (k === 'person' ? 'M' : k === 'scene' ? 'S' : 'P') + String(_hash(name) % 1000).padStart(3, '0'); }\n function _fmtSize(name) { return (4 + (_hash(name) % 100) / 10).toFixed(1) + 'MB'; }\n function _fmtFav(name) { return String(8 + _hash(name) % 80); }\n function _fmtDl(name) { const n = 200 + _hash(name) % 1800; return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n); }\n\n function open(card) {\n // 重置本次打开的状态\n _curCard = card;\n _versions = [];\n _curIdx = -1;\n _dirty = false;\n _generating = false;\n _allowGen = false;\n historyEl.style.display = 'none';\n tipEl.classList.remove('is-loading');\n _setAigenLabel('AI 生成三视图', false);\n\n const name = card.dataset.name || '资产';\n _curName = name;\n const used = card.dataset.used || '0';\n const source = card.dataset.source || '平台预设';\n let tagText = 'AI 素材', intro = '', tags = [], props = [], hasTri = false, isActor = false;\n\n if (card.dataset.gender) {\n tagText = '人物 · 模特';\n isActor = true; hasTri = true;\n intro = (card.dataset.role || '人物模特') + ' · 可用于本项目人物资产生成';\n tags = [card.dataset.gender, card.dataset.age, card.dataset.role].filter(Boolean);\n props = [\n ['性别', card.dataset.gender || '-'], ['种族', '东亚'], ['作品ID', _fmtAssetId(name, 'person')],\n ['年龄段', card.dataset.age || '-'], ['角色', card.dataset.role || '-'], ['创作人', 'Airshelf'],\n ['身高', '中等'], ['来源', source], ['文件大小', _fmtSize(name)],\n ['使用次数', used + ' 次'], ['授权', '商用'], ['发布时间', '2024-05-20'],\n ];\n } else if (card.dataset.sceneType) {\n tagText = '场景 · ' + card.dataset.sceneType;\n isActor = false; hasTri = false;\n intro = card.dataset.sceneType + ' 场景资产';\n tags = [card.dataset.sceneType, source].filter(Boolean);\n props = [\n ['场景类型', card.dataset.sceneType], ['来源', source], ['作品ID', _fmtAssetId(name, 'scene')],\n ['镜头', '通用'], ['光线', '自然光'], ['创作人', 'Airshelf'],\n ['用途', '本项目场景资产'], ['使用次数', used + ' 次'], ['文件大小', _fmtSize(name)],\n ];\n } else if (card.dataset.product) {\n tagText = '商品资产';\n isActor = false;\n intro = '关联商品: ' + card.dataset.product;\n tags = ['商品', source].filter(Boolean);\n props = [\n ['关联商品', card.dataset.product], ['来源', source], ['作品ID', _fmtAssetId(name, 'product')],\n ['用途', '商品资产'], ['使用次数', used + ' 次'], ['创作人', 'Airshelf'],\n ['授权', '商用'], ['文件大小', _fmtSize(name)], ['发布时间', '2024-05-20'],\n ];\n } else {\n tagText = 'AI 素材';\n isActor = false;\n intro = name;\n tags = [source].filter(Boolean);\n props = [\n ['名称', name], ['来源', source], ['作品ID', _fmtAssetId(name, 'asset')],\n ['创作人', 'Airshelf'], ['文件大小', _fmtSize(name)], ['发布时间', '2024-05-20'],\n ];\n }\n\n titleEl.textContent = name;\n kindEl.textContent = '/ ' + tagText;\n leadImg.innerHTML = '<span class=\"ph-frame\">' + name + '</span>';\n // 立绘 zoom 按钮(单次绑定 · 通过 name 闭包始终读最新 _curName)\n const _leadZoomBtn = document.getElementById('lib-detail-lead-zoom');\n if (_leadZoomBtn && !_leadZoomBtn.dataset.bound) {\n _leadZoomBtn.dataset.bound = '1';\n _leadZoomBtn.addEventListener('click', e => {\n e.stopPropagation();\n if (window.Shell?._openLightbox) Shell._openLightbox('', _curName || titleEl.textContent);\n });\n }\n\n // 平台预设资产 · 仅 1 张缩略图(用户上传多张的场景在 pipeline 工作台详情中处理)\n const _thumbLabel = card.dataset.sceneType ? '场景' : (isActor ? '立绘' : '主图');\n thumbsEl.innerHTML = `<div class=\"thumb placeholder active\"><span class=\"ph-frame\">${_thumbLabel}</span></div>`;\n\n // 卡片是否标注「缺三视图」(data-triview=\"0\")\n const cardMissingTri = card.dataset.triview === '0';\n\n if (card.dataset.sceneType) {\n triSection.style.display = 'none';\n } else if (isActor) {\n triSection.style.display = '';\n triEl.classList.remove('actor');\n ratioChip.textContent = '16:9';\n if (cardMissingTri) {\n // 人物 · 缺三视图 → 显示生成入口\n _allowGen = true;\n triEl.innerHTML = '<div class=\"placeholder missing\" data-tri=\"0\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 8v4M12 16h.01\"/></svg><span>暂未生成三视图 · 点击右侧按钮 AI 生成</span></div>';\n tipTextEl.textContent = '暂无三视图,建议用 AI 生成以保证多角度一致性';\n _setAigenLabel('AI 生成三视图', false);\n tipEl.style.display = 'flex';\n } else {\n triEl.innerHTML = '<div class=\"placeholder\"><span class=\"ph-frame\">' + name + ' · 三视图 (正/侧/背)</span><button class=\"ad-zoom-btn\" type=\"button\" data-zoom-tri aria-label=\"查看大图\" title=\"查看大图\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5\"/></svg></button></div>';\n tipEl.style.display = 'none';\n }\n } else {\n triSection.style.display = '';\n triEl.classList.remove('actor');\n ratioChip.textContent = '16:9';\n if (hasTri && !cardMissingTri) {\n triEl.innerHTML = '<div class=\"placeholder\"><span class=\"ph-frame\">' + name + ' · 三视图</span><button class=\"ad-zoom-btn\" type=\"button\" data-zoom-tri aria-label=\"查看大图\" title=\"查看大图\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5\"/></svg></button></div>';\n tipEl.style.display = 'none';\n } else {\n // 商品/其他 · 缺三视图 → 同样启用生成入口\n _allowGen = true;\n triEl.innerHTML = '<div class=\"placeholder missing\" data-tri=\"0\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 8v4M12 16h.01\"/></svg><span>暂未生成三视图(16:9 单图)</span></div>';\n tipTextEl.textContent = '暂无三视图,建议用 AI 生成以保证多角度一致性';\n _setAigenLabel('AI 生成三视图', false);\n tipEl.style.display = 'flex';\n }\n }\n\n // 三视图 zoom 按钮 click\n triEl.querySelector('[data-zoom-tri]')?.addEventListener('click', e => {\n e.stopPropagation();\n if (window.Shell?._openLightbox) Shell._openLightbox('', name + ' · 三视图');\n });\n\n introEl.textContent = intro || '暂无简介';\n tagsEl.innerHTML = tags.map(t => '<span class=\"ad-tag-chip\">' + t + '</span>').join('') +\n '<button class=\"ad-tag-add\" type=\"button\" title=\"添加标签\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"><path d=\"M8 3v10M3 8h10\"/></svg></button>';\n const tagAddBtn = tagsEl.querySelector('.ad-tag-add');\n tagAddBtn?.addEventListener('click', e => {\n e.stopPropagation();\n const existing = [...tagsEl.querySelectorAll('.ad-tag-chip')].map(el => el.textContent);\n const next = ['精选素材', '常用资产', '待复用', '已核准'].find(t => !existing.includes(t)) || '新标签';\n const chip = document.createElement('span');\n chip.className = 'ad-tag-chip';\n chip.textContent = next;\n tagsEl.insertBefore(chip, tagAddBtn);\n Shell.toast('已添加标签', next);\n });\n propsEl.innerHTML = props.map(([k, v]) => '<div class=\"ad-prop\"><span class=\"k\">' + k + '</span><span class=\"v\">' + v + '</span></div>').join('');\n\n bg.classList.add('show');\n }\n // ── 二次确认弹窗 ──\n const confirmBg = document.getElementById('lib-confirm-bg');\n const confirmCountEl = document.getElementById('lib-confirm-count');\n const confirmSaveBtn = document.getElementById('lib-confirm-save');\n const confirmDiscardBtn = document.getElementById('lib-confirm-discard');\n function _openConfirm() {\n confirmCountEl.textContent = _versions.length;\n confirmBg.classList.add('show');\n confirmBg.setAttribute('aria-hidden', 'false');\n }\n function _closeConfirm() {\n confirmBg.classList.remove('show');\n confirmBg.setAttribute('aria-hidden', 'true');\n }\n confirmBg.addEventListener('click', e => { if (e.target === confirmBg) _closeConfirm(); });\n\n function _doSave() {\n if (_curCard) {\n const badge = _curCard.querySelector('.tri-missing-badge');\n if (badge) badge.remove();\n _curCard.dataset.triview = '1';\n }\n _dirty = false;\n Shell.toast('已保存', _curName + ' · 三视图(' + _versions[_curIdx].label + ')已写入资产');\n }\n\n function close(force) {\n if (!force && _dirty) {\n _openConfirm();\n return;\n }\n bg.classList.remove('show');\n _dirty = false;\n }\n\n bg.addEventListener('click', e => { if (e.target === bg) close(); });\n document.getElementById('lib-detail-x').addEventListener('click', () => close());\n\n applyBtn.addEventListener('click', () => {\n if (_allowGen && _versions.length) {\n _doSave();\n close(true);\n return;\n }\n if (_allowGen && !_versions.length) {\n Shell.toast('请先生成三视图', '点击「AI 生成三视图」开始');\n return;\n }\n Shell.toast('已保存', _curName);\n close(true);\n });\n\n bg.querySelectorAll('.ad-icon-btn, .ad-stat-btn').forEach(btn => {\n btn.addEventListener('click', e => {\n e.stopPropagation();\n Shell.toast('已加入下载', (_curName || '资产') + ' · 原图 / 三视图');\n });\n });\n\n // 弹窗按钮 · 主按钮「保存并退出」/ 次按钮「退出」\n confirmSaveBtn.addEventListener('click', () => {\n if (_versions.length) _doSave();\n _closeConfirm();\n close(true);\n });\n confirmDiscardBtn.addEventListener('click', () => {\n _dirty = false;\n _closeConfirm();\n close(true);\n });\n\n aigenBtn.addEventListener('click', _startGenerate);\n\n // Esc · 若确认弹窗开着,先关确认;否则尝试关详情(经过 dirty 检查)\n document.addEventListener('keydown', e => {\n if (e.key !== 'Escape') return;\n if (confirmBg.classList.contains('show')) { _closeConfirm(); return; }\n if (!bg.classList.contains('show')) return;\n close();\n });\n\n // 把所有 .asset-card 的旧 onclick=\"Shell.toast(...)\" 清掉,改成 open(card)\n document.querySelectorAll('.asset-card').forEach(card => {\n if (card.onclick) card.onclick = null;\n card.removeAttribute('onclick');\n card.style.cursor = 'pointer';\n card.addEventListener('click', e => {\n if (document.body.classList.contains('edit-mode')) return; // 编辑模式由更早 capture handler 处理\n if (e.target.closest('.card-del-btn')) return;\n open(card);\n });\n });\n})();\n</script>\n<script src=\"/exact/assets/api-bridge.js?v=2026060114\"></script>\n</body>\n</html>\n",
|
||
"login": "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta name=\"x-airshelf-exact-source\" content=\"login.html\">\n<meta charset=\"utf-8\">\n<title>登录 · Airshelf</title>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"/exact/assets/restraint.css?v=2026060101\">\n<style>\n body { margin: 0; min-height: 100vh; background: var(--background-base); display: grid; place-items: center; padding: 32px 24px; }\n\n .auth-wrap { width: 100%; max-width: 980px; display: grid; grid-template-columns: minmax(0, 1fr) 420px; gap: 0; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); position: relative; overflow: hidden; min-height: 560px; }\n\n /* 4 装订线 */\n .auth-wrap::before, .auth-wrap::after,\n .auth-wrap > .corner-tr, .auth-wrap > .corner-bl {\n content: ''; position: absolute; width: 14px; height: 14px;\n background: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 21' fill='%23e8e8e8'%3E%3Cpath d='M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z'/%3E%3C/svg%3E\") no-repeat center;\n background-size: contain; pointer-events: none; z-index: 2;\n }\n .auth-wrap::before { top: -7px; left: -7px; }\n .auth-wrap::after { bottom: -7px; right: -7px; }\n .auth-wrap > .corner-tr { top: -7px; right: -7px; }\n .auth-wrap > .corner-bl { bottom: -7px; left: -7px; }\n\n /* 左侧品牌区 · 深色 */\n .auth-brand { background: var(--accent-black); color: var(--accent-white); padding: 40px 44px; display: flex; flex-direction: column; position: relative; overflow: hidden; }\n .auth-brand .logo { display: flex; align-items: center; height: 56px; }\n .auth-brand .logo-img { display: block; width: 196px; height: auto; margin: -14px 0 -10px -14px; object-fit: contain; }\n .auth-brand .tag { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.5); letter-spacing: .08em; text-transform: uppercase; margin-top: 4px; }\n\n .auth-brand .hero { margin-top: auto; margin-bottom: auto; }\n .auth-brand .hero h1 { font-size: 30px; font-weight: 700; letter-spacing: -.02em; line-height: 1.2; margin: 0 0 14px; }\n .auth-brand .hero h1 .h { color: var(--heat); }\n .auth-brand .hero p { font-size: 13.5px; color: rgba(255,255,255,.62); line-height: 1.7; max-width: 320px; margin: 0; }\n\n /* ASCII 装饰行 */\n .auth-brand .ascii { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.32); letter-spacing: .04em; line-height: 1.8; margin-top: 28px; }\n .auth-brand .ascii .ln .k { color: rgba(255,255,255,.5); }\n .auth-brand .ascii .ln .v { color: var(--heat); }\n\n .auth-brand .foot { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.32); letter-spacing: .04em; margin-top: 22px; display: flex; gap: 14px; }\n .auth-brand .foot a { color: rgba(255,255,255,.5); text-decoration: none; }\n .auth-brand .foot a:hover { color: var(--heat); }\n\n /* 右侧表单区 */\n .auth-form { padding: 44px 44px 36px; display: flex; flex-direction: column; }\n .auth-form .h-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }\n .auth-form h2 { font-size: 22px; font-weight: 600; letter-spacing: -.012em; margin: 0; }\n .auth-form .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; }\n .auth-form .lead { font-size: 13px; color: var(--black-alpha-56); margin: 0 0 28px; }\n\n .field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }\n .field-label { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--black-alpha-56); font-family: var(--font-mono); letter-spacing: .02em; }\n .field-label .req { color: var(--accent-crimson); }\n .field-input-wrap { position: relative; }\n .field-input-wrap .ic-l { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--black-alpha-32); width: 14px; height: 14px; pointer-events: none; }\n .field input { width: 100%; box-sizing: border-box; padding: 11px 12px 11px 36px; font-size: 13.5px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); font-family: inherit; color: var(--accent-black); transition: border-color var(--t-base), box-shadow var(--t-base); }\n .field input:focus { outline: none; border-color: var(--heat); box-shadow: 0 0 0 3px var(--heat-12); }\n .field input::placeholder { color: var(--black-alpha-32); }\n .field .toggle-pwd { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; color: var(--black-alpha-48); cursor: pointer; background: none; border: 0; padding: 0; display: grid; place-items: center; }\n .field .toggle-pwd:hover { color: var(--accent-black); }\n\n .row-between { display: flex; align-items: center; justify-content: space-between; margin: 4px 0 22px; font-size: 12.5px; }\n .row-between label { display: inline-flex; align-items: center; gap: 6px; color: var(--black-alpha-56); cursor: pointer; user-select: none; }\n .row-between label input { width: 13px; height: 13px; accent-color: var(--heat); }\n .row-between a { color: var(--heat); text-decoration: none; font-family: var(--font-mono); font-size: 11.5px; letter-spacing: .02em; }\n .row-between a:hover { text-decoration: underline; }\n\n .btn-cta { background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: var(--r-md); padding: 12px 14px; font-size: 14px; font-weight: 600; cursor: pointer; font-family: inherit; transition: box-shadow var(--t-base), transform var(--t-base); display: inline-flex; align-items: center; justify-content: center; gap: 8px; }\n .btn-cta:hover { box-shadow: 0 4px 14px rgba(250,93,25,.28); }\n .btn-cta:active { transform: translateY(1px); }\n .btn-cta svg { width: 15px; height: 15px; }\n\n .divider { display: flex; align-items: center; gap: 10px; margin: 22px 0 18px; }\n .divider .line { flex: 1; height: 1px; background: var(--border-faint); }\n .divider .txt { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-32); letter-spacing: .08em; text-transform: uppercase; }\n\n .sso-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }\n .sso-btn { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 10px 12px; font-size: 12.5px; color: var(--accent-black); cursor: pointer; font-family: inherit; display: inline-flex; align-items: center; justify-content: center; gap: 8px; transition: border-color var(--t-base); }\n .sso-btn:hover { border-color: var(--black-alpha-32); }\n .sso-btn svg { width: 14px; height: 14px; }\n\n .switch-row { margin-top: auto; padding-top: 28px; text-align: center; font-size: 12.5px; color: var(--black-alpha-56); }\n .switch-row a { color: var(--heat); text-decoration: none; font-weight: 500; }\n .switch-row a:hover { text-decoration: underline; }\n\n /* 响应式 */\n @media (max-width: 820px) {\n .auth-wrap { grid-template-columns: 1fr; min-height: 0; }\n .auth-brand { padding: 32px 28px; }\n .auth-brand .ascii { display: none; }\n .auth-form { padding: 32px 28px; }\n }\n\n /* 顶部小返回链(可选) */\n .top-back { position: fixed; top: 20px; left: 24px; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); text-decoration: none; letter-spacing: .04em; }\n .top-back:hover { color: var(--heat); }\n</style>\n</head>\n<body>\n<a class=\"top-back\" href=\"index.html\">← 返回工作台</a>\n\n<div class=\"auth-wrap\">\n <span class=\"corner-tr\" aria-hidden></span><span class=\"corner-bl\" aria-hidden></span>\n\n <!-- 左:品牌 -->\n <aside class=\"auth-brand\">\n <div class=\"logo\">\n <img class=\"logo-img\" src=\"/exact/assets/logo-dark.png\" alt=\"Airshelf\">\n </div>\n <div class=\"tag\">// SHORT-VIDEO COMMERCE PLATFORM</div>\n\n <div class=\"hero\">\n <h1>AI 全流程<br><span class=\"h\">短剧化</span>带货生成</h1>\n <p>商品 → AI 脚本 → 故事板 → Seedance 视频片段 → 一键导出 9:16 ≤60s 成片。</p>\n </div>\n\n <div class=\"ascii\">\n <div class=\"ln\"><span class=\"k\">// step·1</span> 脚本生成 <span class=\"v\">●●●●●</span></div>\n <div class=\"ln\"><span class=\"k\">// step·2</span> 基础资产 <span class=\"v\">●●●●○</span></div>\n <div class=\"ln\"><span class=\"k\">// step·3</span> 故事板 <span class=\"v\">●●●○○</span></div>\n <div class=\"ln\"><span class=\"k\">// step·4</span> 视频片段 <span class=\"v\">●●○○○</span></div>\n <div class=\"ln\"><span class=\"k\">// step·5</span> 拼接导出 <span class=\"v\">●○○○○</span></div>\n </div>\n\n <div class=\"foot\">\n <a href=\"#\">关于</a>\n <a href=\"#\">定价</a>\n <a href=\"#\">联系</a>\n <a href=\"#\">隐私</a>\n </div>\n </aside>\n\n <!-- 右:表单 -->\n <main class=\"auth-form\">\n <div class=\"h-row\">\n <h2>登录</h2>\n <span class=\"sub\">// /auth/login</span>\n </div>\n <p class=\"lead\">使用团队邀请邮箱登录,接受邀请后自动加入对应团队。</p>\n\n <form id=\"login-form\" autocomplete=\"off\" onsubmit=\"event.preventDefault(); doLogin();\">\n <div class=\"field\">\n <label class=\"field-label\" for=\"auth-email\">邮箱 <span class=\"req\">*</span></label>\n <div class=\"field-input-wrap\">\n <svg class=\"ic-l\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z\"/><path d=\"m22 6-10 7L2 6\"/></svg>\n <input type=\"email\" id=\"auth-email\" placeholder=\"name@company.com\" value=\"li@shop.com\" required>\n </div>\n </div>\n\n <div class=\"field\">\n <label class=\"field-label\" for=\"auth-pwd\">密码 <span class=\"req\">*</span></label>\n <div class=\"field-input-wrap\">\n <svg class=\"ic-l\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"11\" width=\"18\" height=\"11\" rx=\"2\"/><path d=\"M7 11V7a5 5 0 0 1 10 0v4\"/></svg>\n <input type=\"password\" id=\"auth-pwd\" placeholder=\"••••••••\" value=\"demo-1234\" required>\n <button type=\"button\" class=\"toggle-pwd\" aria-label=\"切换密码可见\" onclick=\"togglePwd()\">\n <svg id=\"pwd-eye\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/></svg>\n </button>\n </div>\n </div>\n\n <div class=\"row-between\">\n <label><input type=\"checkbox\" checked> 记住我 7 天</label>\n <a href=\"#\" onclick=\"event.preventDefault();_loginToast('已发送重置邮件','请到 li@shop.com 收件箱查看 · 链接 30 分钟有效');\">忘记密码?</a>\n </div>\n\n <button class=\"btn-cta\" type=\"submit\">\n 登录\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12h14M12 5l7 7-7 7\"/></svg>\n </button>\n\n <div class=\"divider\"><span class=\"line\"></span><span class=\"txt\">OR</span><span class=\"line\"></span></div>\n\n <div class=\"sso-row\">\n <button type=\"button\" class=\"sso-btn\" onclick=\"_loginToast('微信扫码','请在 60s 内用微信扫一扫完成授权 · 内测中,以邮箱登录为准');\">\n <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M9 4C5 4 2 6.5 2 9.5c0 1.7 1 3.2 2.5 4.2L4 16l2.2-1.1c.7.2 1.5.3 2.3.3-.3-.5-.5-1.1-.5-1.7 0-2.8 2.9-5 6.5-5h.5C14.6 6 12 4 9 4zm-2 3.5c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zm4 0c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zM15 9c-3.3 0-6 2.2-6 5s2.7 5 6 5c.7 0 1.4-.1 2-.3L19 20l-.4-1.7c1.4-.9 2.4-2.3 2.4-3.8 0-2.8-2.7-5-6-5zm-2 2.5c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zm4 0c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1z\"/></svg>\n 微信扫码\n </button>\n <button type=\"button\" class=\"sso-btn\" onclick=\"_loginToast('飞书 SSO','即将打开企业飞书登录授权页 · 内测中,以邮箱登录为准');\">\n <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"3\"/></svg>\n 飞书 SSO\n </button>\n </div>\n\n <div class=\"switch-row\">\n 还没账号? <a href=\"register.html\">注册团队 →</a>\n </div>\n </form>\n </main>\n</div>\n\n<script>\n function togglePwd() {\n const inp = document.getElementById('auth-pwd');\n inp.type = inp.type === 'password' ? 'text' : 'password';\n }\n function doLogin() {\n const btn = document.querySelector('.btn-cta');\n btn.innerHTML = '<span style=\"font-family:var(--font-mono);font-size:12px;letter-spacing:.04em;\">// 验证中...</span>';\n btn.disabled = true;\n setTimeout(() => { location.href = 'index.html'; }, 700);\n }\n // 轻量 toast(不依赖 shell.js,免登录页拉重)\n function _loginToast(t, sub) {\n let el = document.getElementById('__login-toast');\n if (!el) {\n el = document.createElement('div');\n el.id = '__login-toast';\n el.style.cssText = 'position:fixed;left:50%;bottom:36px;transform:translateX(-50%) translateY(20px);background:#fff;border:1px solid #e0e0e0;border-radius:8px;padding:12px 18px;box-shadow:0 8px 24px rgba(0,0,0,.12);display:flex;flex-direction:column;gap:2px;opacity:0;transition:opacity .2s,transform .2s;z-index:9999;font-family:inherit;max-width:360px;';\n document.body.appendChild(el);\n }\n el.innerHTML = '<div style=\"font-size:13.5px;font-weight:600;color:#262626;\">' + t + '</div>' + (sub ? '<div style=\"font-size:11.5px;color:rgba(0,0,0,.56);font-family:\\'Inter\\',system-ui,sans-serif;letter-spacing:.02em;\">// ' + sub + '</div>' : '');\n requestAnimationFrame(() => { el.style.opacity = '1'; el.style.transform = 'translateX(-50%) translateY(0)'; });\n clearTimeout(el._t);\n el._t = setTimeout(() => { el.style.opacity = '0'; el.style.transform = 'translateX(-50%) translateY(20px)'; }, 2800);\n }\n</script>\n<script src=\"/exact/assets/api-bridge.js?v=2026060114\"></script>\n</body>\n</html>\n",
|
||
"messages": "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta name=\"x-airshelf-exact-source\" content=\"messages.html\">\n<meta charset=\"utf-8\">\n<title>消息中心 · Airshelf</title>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"/exact/assets/restraint.css?v=2026060101\">\n<style>\n .msg-page {\n display: flex;\n flex-direction: column;\n gap: 16px;\n }\n .msg-page .page-head {\n margin-bottom: 0;\n }\n .msg-head-actions {\n display: inline-flex;\n align-items: center;\n gap: 10px;\n }\n .msg-workbench {\n display: grid;\n grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);\n min-height: 640px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n overflow: hidden;\n }\n .msg-panel {\n min-width: 0;\n background: transparent;\n border: 0;\n border-radius: 0;\n overflow: hidden;\n }\n .msg-inbox,\n .msg-detail {\n display: flex;\n flex-direction: column;\n min-height: 0;\n }\n .msg-inbox { border-right: 1px solid var(--border-faint); }\n .msg-panel-h {\n min-height: 58px;\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 14px 16px;\n border-bottom: 1px solid var(--border-faint);\n }\n .msg-panel-h .ti {\n font-size: 13px;\n font-weight: 600;\n color: var(--accent-black);\n }\n .msg-panel-h .mono {\n margin-left: auto;\n font-family: var(--font-mono);\n font-size: 10.5px;\n color: var(--black-alpha-48);\n letter-spacing: .04em;\n }\n .msg-filters {\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n padding: 12px 14px;\n border-bottom: 1px solid var(--border-faint);\n }\n .msg-filter {\n height: 30px;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 0 10px;\n border: 1px solid var(--border-faint);\n border-radius: var(--r-pill);\n background: var(--surface);\n color: var(--black-alpha-56);\n font-family: inherit;\n font-size: 12px;\n cursor: pointer;\n }\n .msg-filter:hover {\n border-color: var(--black-alpha-24);\n color: var(--accent-black);\n background: var(--black-alpha-4);\n }\n .msg-filter.active {\n border-color: var(--heat-20);\n background: var(--heat-12);\n color: var(--heat);\n font-weight: 600;\n }\n .msg-filter .ct {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: .02em;\n }\n .msg-search {\n position: relative;\n padding: 0 14px 12px;\n border-bottom: 1px solid var(--border-faint);\n }\n .msg-search svg {\n position: absolute;\n left: 26px;\n top: 10px;\n width: 13px;\n height: 13px;\n color: var(--black-alpha-48);\n pointer-events: none;\n }\n .msg-search input {\n width: 100%;\n height: 34px;\n padding: 0 12px 0 32px;\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n background: var(--background-lighter);\n color: var(--accent-black);\n font-family: inherit;\n font-size: 13px;\n outline: none;\n }\n .msg-search input:focus {\n background: var(--surface);\n border-color: var(--heat-40);\n box-shadow: inset 0 0 0 1px var(--heat-40);\n }\n .msg-list {\n flex: 1;\n min-height: 0;\n overflow-y: auto;\n }\n .msg-item {\n position: relative;\n width: 100%;\n display: grid;\n grid-template-columns: 30px minmax(0, 1fr);\n gap: 10px;\n padding: 14px 16px;\n border: 0;\n border-bottom: 1px solid var(--border-faint);\n background: transparent;\n font-family: inherit;\n text-align: left;\n cursor: pointer;\n }\n .msg-item:hover { background: var(--black-alpha-4); }\n .msg-item.active { background: var(--heat-12); }\n .msg-item.active::before {\n content: \"\";\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n width: 3px;\n background: var(--heat);\n }\n .msg-item.read .msg-item-title { color: var(--black-alpha-56); font-weight: 500; }\n .msg-type-ic {\n width: 30px;\n height: 30px;\n display: grid;\n place-items: center;\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n background: var(--background-lighter);\n color: var(--black-alpha-72);\n }\n .msg-type-ic svg { width: 14px; height: 14px; }\n .msg-type-ic.task { background: var(--heat-12); border-color: var(--heat-20); color: var(--heat); }\n .msg-type-ic.team { background: var(--black-alpha-4); color: var(--accent-black); }\n .msg-type-ic.billing { background: var(--honey-bg); border-color: var(--honey-bd); color: var(--accent-honey); }\n .msg-type-ic.system { background: var(--black-alpha-7); color: var(--black-alpha-72); }\n .msg-item-main { min-width: 0; }\n .msg-item-row {\n display: flex;\n align-items: center;\n gap: 8px;\n }\n .msg-dot {\n width: 7px;\n height: 7px;\n border-radius: var(--r-pill);\n background: var(--heat);\n flex-shrink: 0;\n }\n .msg-item.read .msg-dot { display: none; }\n .msg-item-title {\n flex: 1;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--accent-black);\n font-size: 13px;\n font-weight: 600;\n }\n .msg-time {\n flex-shrink: 0;\n color: var(--black-alpha-48);\n font-family: var(--font-mono);\n font-size: 10.5px;\n letter-spacing: .02em;\n }\n .msg-brief {\n margin-top: 4px;\n color: var(--black-alpha-56);\n font-size: 12px;\n line-height: 1.55;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n }\n .msg-item-foot {\n display: flex;\n align-items: center;\n gap: 6px;\n margin-top: 8px;\n }\n .msg-priority {\n display: inline-flex;\n align-items: center;\n height: 20px;\n padding: 0 7px;\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n background: var(--background-lighter);\n color: var(--black-alpha-56);\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: .02em;\n }\n .msg-priority.ok { background: var(--forest-bg); border-color: var(--forest-bd); color: var(--accent-forest); }\n .msg-priority.warn { background: var(--honey-bg); border-color: var(--honey-bd); color: var(--accent-honey); }\n .msg-priority.err { background: var(--crimson-bg); border-color: var(--crimson-bd); color: var(--accent-crimson); }\n .msg-priority.info { background: var(--heat-12); border-color: var(--heat-20); color: var(--heat); }\n .msg-empty {\n min-height: 320px;\n display: grid;\n place-items: center;\n gap: 8px;\n padding: 24px;\n color: var(--black-alpha-48);\n font-size: 13px;\n text-align: center;\n }\n .msg-empty svg { width: 24px; height: 24px; color: var(--black-alpha-40); }\n .msg-detail-empty {\n flex: 1;\n min-height: 520px;\n display: grid;\n place-items: center;\n gap: 8px;\n color: var(--black-alpha-48);\n text-align: center;\n }\n .msg-detail-empty .ic {\n width: 46px;\n height: 46px;\n display: grid;\n place-items: center;\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n background: var(--background-lighter);\n }\n .msg-detail-empty svg { width: 21px; height: 21px; }\n .msg-detail-body {\n flex: 1;\n min-height: 0;\n overflow-y: auto;\n padding: 22px 24px 24px;\n }\n .msg-detail-top {\n display: flex;\n align-items: flex-start;\n gap: 12px;\n padding-bottom: 18px;\n border-bottom: 1px solid var(--border-faint);\n }\n .msg-detail-title {\n min-width: 0;\n flex: 1;\n }\n .msg-detail-title h2 {\n margin: 0;\n font-size: 20px;\n line-height: 1.35;\n font-weight: 600;\n letter-spacing: -.012em;\n color: var(--accent-black);\n }\n .msg-detail-title .meta {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n margin-top: 8px;\n color: var(--black-alpha-48);\n font-family: var(--font-mono);\n font-size: 10.5px;\n letter-spacing: .04em;\n }\n .msg-body-text {\n margin: 18px 0 0;\n color: var(--accent-black);\n font-size: 14px;\n line-height: 1.75;\n }\n .msg-props {\n display: grid;\n grid-template-columns: 110px 1fr;\n gap: 10px 16px;\n margin-top: 18px;\n padding: 14px 16px;\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n background: var(--background-lighter);\n }\n .msg-props .k {\n color: var(--black-alpha-48);\n font-family: var(--font-mono);\n font-size: 10.5px;\n letter-spacing: .04em;\n }\n .msg-props .v {\n min-width: 0;\n color: var(--accent-black);\n font-size: 13px;\n }\n .msg-props .v a { color: var(--heat); }\n .msg-timeline {\n margin-top: 18px;\n padding: 16px;\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n }\n .msg-timeline-h {\n margin-bottom: 12px;\n color: var(--accent-black);\n font-size: 13px;\n font-weight: 600;\n }\n .msg-step {\n display: grid;\n grid-template-columns: 68px 1fr;\n gap: 12px;\n padding: 10px 0;\n border-top: 1px solid var(--border-faint);\n }\n .msg-step:first-of-type { border-top: 0; padding-top: 0; }\n .msg-step .t {\n color: var(--black-alpha-48);\n font-family: var(--font-mono);\n font-size: 10.5px;\n letter-spacing: .02em;\n }\n .msg-step .d {\n color: var(--black-alpha-72);\n font-size: 12.5px;\n line-height: 1.55;\n }\n .msg-log {\n margin-top: 14px;\n padding: 12px 14px;\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n background: var(--accent-black);\n color: var(--accent-white);\n font-family: var(--font-mono);\n font-size: 11px;\n line-height: 1.7;\n letter-spacing: .02em;\n white-space: pre-wrap;\n }\n .msg-detail-f {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 14px 16px;\n border-top: 1px solid var(--border-faint);\n background: var(--background-lighter);\n }\n .msg-detail-f .spacer { flex: 1; }\n .msg-foot-note {\n display: flex;\n align-items: center;\n gap: 8px;\n color: var(--black-alpha-48);\n font-family: var(--font-mono);\n font-size: 10.5px;\n letter-spacing: .04em;\n }\n .msg-foot-note a { color: var(--heat); }\n @media (max-width: 1280px) {\n .msg-workbench { grid-template-columns: minmax(300px, 340px) minmax(0, 1fr); }\n }\n @media (max-width: 860px) {\n .msg-workbench { grid-template-columns: 1fr; }\n .msg-inbox { border-right: 0; border-bottom: 1px solid var(--border-faint); }\n .msg-list { max-height: 360px; }\n }\n</style>\n</head>\n<body>\n<div id=\"page\">\n <div class=\"msg-page\">\n <div class=\"page-head\">\n <div>\n <h1>消息中心</h1>\n <div class=\"sub\"><span class=\"mono\" id=\"msg-head-sub\">// INBOX</span> 任务提醒 · 团队协作 · 计费与系统公告</div>\n </div>\n <div class=\"msg-head-actions\">\n <button class=\"btn\" type=\"button\" id=\"msg-mark-all\">全部标已读</button>\n <button class=\"btn\" type=\"button\" id=\"msg-settings\">通知设置</button>\n </div>\n </div>\n\n <div class=\"msg-workbench\">\n <section class=\"msg-panel msg-inbox\">\n <div class=\"msg-panel-h\">\n <span class=\"ti\">收件箱</span>\n <span class=\"mono\" id=\"msg-list-count\">// 0 条</span>\n </div>\n <div class=\"msg-filters\" id=\"msg-filters\"></div>\n <div class=\"msg-search\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/></svg>\n <input id=\"msg-search\" type=\"text\" placeholder=\"搜索项目、来源、内容\">\n </div>\n <div class=\"msg-list\" id=\"msg-list\"></div>\n </section>\n\n <section class=\"msg-panel msg-detail\" id=\"msg-detail\"></section>\n </div>\n\n <div class=\"msg-foot-note\">\n <span>// 消息保留 90 天 · 高风险任务会同时进入工作台队列</span>\n <a href=\"settings.html#sec-notify\">管理通知策略 →</a>\n </div>\n </div>\n</div>\n\n<script src=\"/exact/assets/icons.js?v=2026052608\"></script>\n<script src=\"/exact/assets/shell.js?v=2026060101\"></script>\n<script>\nShell.render({ active: '', crumbs: [{ label: '工作台', href: 'index.html' }, { label: '消息中心' }] });\n\n(function () {\n const NOW = new Date('2026-05-28T10:30:00');\n const $ = sel => document.querySelector(sel);\n const icon = name => window.IconKit ? window.IconKit.svg(name) : '';\n const esc = s => String(s == null ? '' : s).replace(/[&<>\"']/g, c => ({ '&':'&', '<':'<', '>':'>', '\"':'"', \"'\":''' }[c]));\n const mins = n => new Date(NOW.getTime() - n * 60000);\n const zhType = { all: '全部', unread: '未读', task: '任务', team: '团队', billing: '计费', system: '系统' };\n const typeIcon = { task: 'clapperboard', team: 'users', billing: 'creditCard', system: 'info' };\n const priLabel = { ok: '已完成', warn: '需关注', err: '风险', info: '更新' };\n\n function fmtTime(d) {\n const diff = Math.round((NOW - d) / 60000);\n if (diff < 60) return diff + 'm';\n if (diff < 1440) return Math.floor(diff / 60) + 'h';\n return Math.floor(diff / 1440) + 'd';\n }\n function fmtFull(d) {\n const z = n => String(n).padStart(2, '0');\n return d.getFullYear() + '-' + z(d.getMonth() + 1) + '-' + z(d.getDate()) + ' ' + z(d.getHours()) + ':' + z(d.getMinutes());\n }\n\n const messages = [\n {\n id: 'task-video-ready', type: 'task', priority: 'ok', unread: true,\n title: '补水面膜 · 痛点种草 v3 成片已完成',\n brief: '7 镜 · 40 秒 · ¥18.40 已结算,可确认进入投放阶段。',\n body: 'Seedance 视频生成全部完成,本次输出 3 条 9:16 成片。系统检测到镜头 3 的停留率预测最高,建议优先作为首版投放素材。',\n source: '视频生成队列', project: '补水面膜', stage: 'Stage 5 · 拼接导出', owner: '李', cost: '¥18.40',\n time: mins(12), href: 'pipeline.html?product=' + encodeURIComponent('补水面膜'),\n log: 'render_id=vid-20260528-1030\\nstatus=ok\\nclips=7\\nratio=9:16\\ncharge=18.40',\n timeline: [['10:06', '脚本与镜头配置锁定'], ['10:14', '3 条视频完成生成'], ['10:26', '字幕/BGM 合成完成'], ['10:30', '成片已入库,等待确认投放']],\n actions: [{ id: 'goto', label: '进入项目', primary: true }, { id: 'preview', label: '查看成片' }, { id: 'rerun', label: '重跑视频' }],\n },\n {\n id: 'task-script-failed', type: 'task', priority: 'err', unread: true,\n title: '脚本生成失败 · 618 大促',\n brief: 'Prompt 超过服务限制,本次失败未扣费,可一键重试。',\n body: '脚本助手收到的商品卖点和参考文案过长,超过当前脚本模型单次上下文限制。系统已保存失败日志,并建议先压缩参考内容再重跑。',\n source: '脚本助手', project: '618 大促', stage: 'Stage 1 · 脚本', owner: '张', cost: '¥0.00',\n time: mins(64), href: 'pipeline.html?product=' + encodeURIComponent('618 大促'),\n log: 'request_id=script-413\\nerror=context_limit\\nprompt_tokens=8124\\nmax_tokens=8000\\ncharged=false',\n timeline: [['09:12', '提交脚本生成'], ['09:13', '模型返回 context_limit'], ['09:13', '费用回滚完成']],\n actions: [{ id: 'rerun', label: '重试生成', primary: true }, { id: 'log', label: '查看日志' }],\n },\n {\n id: 'task-asset-done', type: 'task', priority: 'ok', unread: true,\n title: '4 张模特上身图已加入资产库',\n brief: '祛痘精华 · 通勤白领 · 3:4,可直接进入视频项目。',\n body: '模特上身图通过基础审核,已同步到资产库「未分类」。建议把其中 2 张加入祛痘精华项目作为 Stage 3 镜头参考。',\n source: '图片生成', project: '祛痘精华', stage: '图片生成 · 模特上身图', owner: '陈', cost: '¥3.20',\n time: mins(140), href: 'library.html',\n timeline: [['08:05', '收到 2 张参考图'], ['08:08', '生成 4 张候选'], ['08:11', '通过筛选并写入资产库']],\n actions: [{ id: 'goto', label: '查看素材', primary: true }, { id: 'attach', label: '加入项目' }],\n },\n {\n id: 'team-edit-lock', type: 'team', priority: 'info', unread: true,\n title: '@刘 正在编辑「补水面膜」项目',\n brief: '你暂时以只读方式查看该项目,避免两人同时改动覆盖。',\n body: '系统检测到同项目正在被团队成员编辑。为避免脚本、镜头或资产配置被互相覆盖,后进入者会进入只读查看状态。编辑结束后会自动恢复操作权限。',\n source: '协作锁定', project: '补水面膜', stage: 'Stage 3 · 镜头', owner: '刘', cost: '-',\n time: mins(210), href: 'pipeline.html?product=' + encodeURIComponent('补水面膜') + '#stage-3',\n timeline: [['07:00', '刘进入项目编辑'], ['07:01', '系统为后进入成员开启只读状态']],\n actions: [{ id: 'goto', label: '查看项目', primary: true }, { id: 'ack', label: '我已了解' }],\n },\n {\n id: 'team-member', type: 'team', priority: 'info', unread: false,\n title: '@王芳 已加入团队',\n brief: '角色为运营,月限额 ¥800,可读写团队资产。',\n body: '新成员已完成邮箱验证。她可以查看并编辑团队项目,但不能调整计费、安全与团队角色设置。',\n source: '@李(超管)', project: '团队', stage: '成员管理', owner: '王', cost: '-',\n time: mins(320), href: 'team.html',\n timeline: [['05:02', '邀请邮件送达'], ['05:18', '成员完成验证'], ['05:19', '角色权限生效']],\n actions: [{ id: 'goto', label: '查看团队', primary: true }, { id: 'settings', label: '调整权限' }],\n },\n {\n id: 'billing-low', type: 'billing', priority: 'warn', unread: false,\n title: '团队余额低于预警线',\n brief: '当前余额 ¥87.20,按近 7 天速度预计可支撑 4 个视频项目。',\n body: '余额预警阈值为 ¥100。若今天继续批量生成视频,建议先充值或临时降低成员月限额。',\n source: '计费中心', project: '消费', stage: '余额监控', owner: '系统', cost: '¥87.20',\n time: mins(460), href: 'account.html',\n timeline: [['02:50', '余额低于 ¥100'], ['02:51', '已发送站内通知']],\n actions: [{ id: 'goto', label: '前往充值', primary: true }, { id: 'settings', label: '预警设置' }],\n },\n {\n id: 'system-maintenance', type: 'system', priority: 'info', unread: false,\n title: '视频生成服务今晚例行维护',\n brief: '23:00 - 01:00 期间提交任务会自动排队。',\n body: '维护期间已进入生成中的任务不受影响。新提交的视频生成、三视图生成会进入队列,维护结束后按提交时间继续处理。',\n source: 'Airshelf 运维', project: '系统', stage: '服务公告', owner: '系统', cost: '-',\n time: mins(720), href: '',\n timeline: [['昨天', '发布维护窗口'], ['今晚', '队列自动延后处理']],\n actions: [{ id: 'ack', label: '我已了解', primary: true }],\n },\n {\n id: 'system-review', type: 'system', priority: 'ok', unread: false,\n title: '祛痘精华首版素材通过平台审核',\n brief: '4 张图 / 2 条视频审核通过,可进入投放。',\n body: '素材合规审核耗时 1 小时 12 分钟。系统未发现夸大功效、违禁词或画面遮挡问题。',\n source: '审核中台', project: '祛痘精华', stage: 'Stage 5 · 投放', owner: '系统', cost: '-',\n time: mins(1120), href: 'pipeline.html?product=' + encodeURIComponent('祛痘精华'),\n timeline: [['昨天 16:40', '提交平台审核'], ['昨天 17:52', '审核通过'], ['昨天 17:53', '写入项目状态']],\n actions: [{ id: 'goto', label: '进入投放', primary: true }],\n },\n ];\n\n const state = { tab: 'all', q: '', selectedId: messages[0].id, showLogId: null };\n\n function counts() {\n return {\n unread: messages.filter(m => m.unread).length,\n task: messages.filter(m => m.type === 'task').length,\n team: messages.filter(m => m.type === 'team').length,\n billing: messages.filter(m => m.type === 'billing').length,\n system: messages.filter(m => m.type === 'system').length,\n risk: messages.filter(m => m.priority === 'err' || m.priority === 'warn').length,\n };\n }\n function visibleMessages() {\n const q = state.q.toLowerCase();\n return messages.filter(m => {\n if (state.tab === 'unread' && !m.unread) return false;\n if (!['all', 'unread'].includes(state.tab) && m.type !== state.tab) return false;\n if (q && ![m.title, m.brief, m.body, m.source, m.project, m.stage].join(' ').toLowerCase().includes(q)) return false;\n return true;\n });\n }\n\n function renderSummary() {\n const c = counts();\n $('#msg-head-sub').textContent = '// ' + c.unread + ' 条未读 · ' + messages.length + ' 条总计';\n }\n\n function renderFilters() {\n const c = counts();\n const filters = [\n ['all', '全部', messages.length],\n ['unread', '未读', c.unread],\n ['task', '任务', c.task],\n ['team', '团队', c.team],\n ['billing', '计费', c.billing],\n ['system', '系统', c.system],\n ];\n $('#msg-filters').innerHTML = filters.map(([id, label, ct]) => `\n <button class=\"msg-filter${state.tab === id ? ' active' : ''}\" type=\"button\" data-tab=\"${id}\">\n ${label}<span class=\"ct\">${ct}</span>\n </button>\n `).join('');\n $('#msg-filters').querySelectorAll('[data-tab]').forEach(btn => {\n btn.addEventListener('click', () => {\n state.tab = btn.dataset.tab;\n render();\n });\n });\n }\n\n function renderList() {\n const list = visibleMessages();\n $('#msg-list-count').textContent = '// 显示 ' + list.length + ' 条';\n if (!list.length) {\n $('#msg-list').innerHTML = `<div class=\"msg-empty\">${icon('search')}<span>没有符合条件的消息</span></div>`;\n return;\n }\n $('#msg-list').innerHTML = list.map(m => `\n <button class=\"msg-item${state.selectedId === m.id ? ' active' : ''}${m.unread ? '' : ' read'}\" type=\"button\" data-id=\"${esc(m.id)}\">\n <span class=\"msg-type-ic ${esc(m.type)}\">${icon(typeIcon[m.type] || 'info')}</span>\n <span class=\"msg-item-main\">\n <span class=\"msg-item-row\">\n <span class=\"msg-dot\"></span>\n <span class=\"msg-item-title\">${esc(m.title)}</span>\n <span class=\"msg-time\">${fmtTime(m.time)}</span>\n </span>\n <span class=\"msg-brief\">${esc(m.brief)}</span>\n <span class=\"msg-item-foot\">\n <span class=\"msg-priority ${esc(m.priority)}\">${esc(priLabel[m.priority] || '更新')}</span>\n <span class=\"msg-priority\">${esc(m.project)}</span>\n </span>\n </span>\n </button>\n `).join('');\n $('#msg-list').querySelectorAll('[data-id]').forEach(btn => {\n btn.addEventListener('click', () => {\n state.selectedId = btn.dataset.id;\n const msg = messages.find(m => m.id === state.selectedId);\n if (msg) msg.unread = false;\n render();\n });\n });\n }\n\n function renderDetail() {\n const m = messages.find(x => x.id === state.selectedId) || visibleMessages()[0] || messages[0];\n if (!m) {\n $('#msg-detail').innerHTML = `<div class=\"msg-detail-empty\"><div class=\"ic\">${icon('bell')}</div><div>暂无消息</div></div>`;\n return;\n }\n state.selectedId = m.id;\n const props = [\n ['来源', m.source],\n ['类别', zhType[m.type]],\n ['项目', m.project],\n ['阶段', m.stage],\n ['负责人', m.owner],\n ['费用', m.cost],\n ['时间', fmtFull(m.time)],\n ['关联资源', m.href ? `<a href=\"${esc(m.href)}\">${esc(m.project)} →</a>` : '无'],\n ].map(([k, v]) => `<span class=\"k\">${k}</span><span class=\"v\">${v}</span>`).join('');\n const actions = m.actions.map(a => `<button class=\"btn${a.primary ? ' btn-primary' : ''}\" type=\"button\" data-action=\"${esc(a.id)}\">${esc(a.label)}</button>`).join('');\n $('#msg-detail').innerHTML = `\n <div class=\"msg-detail-body\">\n <div class=\"msg-detail-top\">\n <span class=\"msg-type-ic ${esc(m.type)}\">${icon(typeIcon[m.type] || 'info')}</span>\n <div class=\"msg-detail-title\">\n <h2>${esc(m.title)}</h2>\n <div class=\"meta\"><span>${esc(m.source)}</span><span>// ${esc(zhType[m.type])}</span><span>${fmtFull(m.time)}</span></div>\n </div>\n <span class=\"msg-priority ${esc(m.priority)}\">${esc(priLabel[m.priority] || '更新')}</span>\n </div>\n <p class=\"msg-body-text\">${esc(m.body)}</p>\n <div class=\"msg-props\">${props}</div>\n <div class=\"msg-timeline\">\n <div class=\"msg-timeline-h\">处理记录</div>\n ${m.timeline.map(([t, d]) => `<div class=\"msg-step\"><span class=\"t\">${esc(t)}</span><span class=\"d\">${esc(d)}</span></div>`).join('')}\n </div>\n ${state.showLogId === m.id && m.log ? `<pre class=\"msg-log\">${esc(m.log)}</pre>` : ''}\n </div>\n <div class=\"msg-detail-f\">\n <button class=\"btn btn-ghost\" type=\"button\" data-action=\"archive\">归档</button>\n <button class=\"btn btn-ghost\" type=\"button\" data-action=\"mute\">静音同类</button>\n <span class=\"spacer\"></span>\n ${actions}\n </div>\n `;\n $('#msg-detail').querySelectorAll('[data-action]').forEach(btn => {\n btn.addEventListener('click', () => runAction(m, btn.dataset.action));\n });\n }\n\n function runAction(m, act) {\n if (!m) return;\n if (act === 'goto') {\n if (m.href) location.href = m.href;\n else location.href = 'settings.html#sec-notify';\n return;\n }\n if (act === 'preview') { Shell.toast('打开视频预览', m.project + ' · 成片'); return; }\n if (act === 'rerun') {\n m.priority = 'info';\n m.brief = '任务已重新排队,完成后会再次提醒。';\n Shell.toast('已重新排队', m.project + ' · ' + m.stage);\n render();\n return;\n }\n if (act === 'log') { state.showLogId = state.showLogId === m.id ? null : m.id; renderDetail(); return; }\n if (act === 'attach') { location.href = 'projects-new.html'; return; }\n if (act === 'settings') { location.href = 'settings.html#sec-notify'; return; }\n if (act === 'ack') { m.unread = false; Shell.toast('已确认', m.title); render(); return; }\n if (act === 'archive') {\n const i = messages.findIndex(x => x.id === m.id);\n if (i >= 0) messages.splice(i, 1);\n state.selectedId = messages[0]?.id || null;\n Shell.toast('已归档', m.title);\n render();\n return;\n }\n if (act === 'mute') { Shell.toast('已静音同类', zhType[m.type] + ' 类提醒可在设置恢复'); return; }\n }\n\n function render() {\n renderSummary();\n renderFilters();\n renderList();\n renderDetail();\n }\n\n $('#msg-search').addEventListener('input', e => {\n state.q = e.target.value.trim();\n renderList();\n });\n $('#msg-mark-all').addEventListener('click', () => {\n messages.forEach(m => { m.unread = false; });\n Shell.toast('已全部标为已读', messages.length + ' 条');\n render();\n });\n $('#msg-settings').addEventListener('click', () => { location.href = 'settings.html#sec-notify'; });\n\n render();\n})();\n</script>\n<script src=\"/exact/assets/api-bridge.js?v=2026060114\"></script>\n</body>\n</html>\n",
|
||
"modelPhoto": "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta name=\"x-airshelf-exact-source\" content=\"model-photo.html\">\n<meta charset=\"utf-8\">\n<title>模特上身图 · Airshelf</title>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"/exact/assets/restraint.css?v=2026060101\">\n<style>\n /* viewport-fit · 工作台铺满 (跟 demo-a 一致 · padding:0) */\n .app { height: 100vh; overflow: hidden; }\n main { display: flex; flex-direction: column; min-height: 0; }\n #page-content { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0; }\n\n .mp-layout {\n flex: 1; min-height: 0;\n display: grid;\n grid-template-columns: 260px 1fr;\n transition: grid-template-columns var(--t-base);\n }\n .mp-layout.side-collapsed { grid-template-columns: 0 1fr; }\n @media (max-width: 1280px) {\n .mp-layout { grid-template-columns: 240px 1fr; }\n .mp-layout.side-collapsed { grid-template-columns: 0 1fr; }\n }\n\n /* ─── 主区: flat 双区 (头部 + body) · 无 card · 用 border 分隔 ─── */\n .mp-main {\n display: flex; flex-direction: column;\n min-height: 0;\n overflow: hidden;\n }\n /* 主区头部 · toolbar 风格 (跟 image-optimize 一致) */\n .mp-main-h {\n flex-shrink: 0;\n position: relative; z-index: 20;\n display: flex; align-items: center; gap: 10px;\n padding: 12px 28px;\n border-bottom: 1px solid var(--border-faint);\n background: var(--surface);\n }\n .mp-main-h .cur-title {\n display: flex; align-items: baseline; gap: 8px;\n min-width: 0; max-width: 50%;\n }\n .mp-main-h .cur-title .crumb {\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48); letter-spacing: .04em;\n flex-shrink: 0;\n }\n .mp-main-h .cur-title .nm {\n font-size: 15px; font-weight: 600;\n color: var(--accent-black);\n letter-spacing: -.005em;\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n }\n .mp-main-h .cur-title .nm.placeholder {\n font-weight: 400; font-size: 13px;\n color: var(--black-alpha-48);\n }\n .mp-main-h .spacer { flex: 1; }\n .mp-main-h .side-restore-btn {\n height: 32px;\n padding: 0 10px;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--black-alpha-72);\n font-family: inherit;\n font-size: 12.5px;\n cursor: pointer;\n }\n .mp-main-h .side-restore-btn:hover { border-color: var(--heat-20); color: var(--heat); }\n .mp-main-h .side-restore-btn[hidden] { display: none; }\n .mp-main-h .side-restore-btn svg { width: 14px; height: 14px; }\n .mp-main-h .search-btn {\n width: 32px; height: 32px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--black-alpha-72);\n cursor: pointer;\n display: grid; place-items: center;\n transition: border-color var(--t-base), color var(--t-base);\n }\n .mp-main-h .search-btn:hover { border-color: var(--heat-20); color: var(--heat); }\n .mp-main-h .search-btn svg { width: 14px; height: 14px; }\n .mp-main-h .tb-chip {\n display: inline-flex; align-items: center; gap: 6px;\n height: 32px; padding: 0 10px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n font-size: 12.5px; color: var(--black-alpha-72);\n font-family: inherit; cursor: pointer;\n transition: border-color var(--t-base), color var(--t-base);\n }\n .mp-main-h .tb-chip:hover { border-color: var(--heat-20); color: var(--heat); }\n .mp-main-h .tb-chip svg { width: 10px; height: 10px; opacity: .6; }\n .mp-main-h .tb-chip.active {\n background: var(--heat-12);\n border-color: var(--heat-40);\n color: var(--heat);\n }\n .mp-main-h .tb-chip.active svg { opacity: .8; }\n .mp-main-h .tb-chip.active .lbl::after {\n content: ':';\n margin: 0 2px 0 0;\n }\n\n /* search · 折叠图标态 + 展开输入框 */\n .mp-main-h .tb-search-wrap { display: flex; align-items: center; }\n .mp-main-h .tb-search-input {\n width: 0; height: 32px;\n padding: 0; margin: 0;\n border: 1px solid transparent;\n background: transparent;\n border-radius: var(--r-sm);\n font-size: 12.5px; color: var(--accent-black);\n font-family: inherit; outline: none;\n transition: width var(--t-base), padding var(--t-base), border-color var(--t-base), margin var(--t-base), background var(--t-base);\n }\n .mp-main-h .tb-search-wrap.expanded .tb-search-input {\n width: 220px;\n padding: 0 10px;\n margin-left: 6px;\n background: var(--surface);\n border-color: var(--border-faint);\n }\n .mp-main-h .tb-search-wrap.expanded .tb-search-input:focus { border-color: var(--heat-40); }\n .mp-main-h .tb-search-wrap.expanded .search-btn { border-color: var(--heat-40); color: var(--heat); }\n\n /* dropdown · chip 下拉菜单 */\n .mp-main-h .tb-menu-wrap { position: relative; }\n .mp-main-h .tb-menu {\n position: absolute;\n top: calc(100% + 6px); right: 0;\n min-width: 160px; max-width: 260px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n box-shadow: 0 8px 24px rgba(0,0,0,.08);\n padding: 4px;\n z-index: 50;\n display: none;\n max-height: 320px; overflow-y: auto;\n }\n .mp-main-h .tb-menu-wrap.open .tb-menu { display: block; }\n .mp-main-h .tb-menu-item {\n display: flex; align-items: center; gap: 8px;\n width: 100%; padding: 7px 10px;\n background: transparent; border: 0;\n border-radius: 4px;\n font-size: 12.5px; color: var(--black-alpha-72);\n font-family: inherit; cursor: pointer; text-align: left;\n transition: background var(--t-base), color var(--t-base);\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n }\n .mp-main-h .tb-menu-item:hover { background: var(--background-lighter); color: var(--accent-black); }\n .mp-main-h .tb-menu-item.active {\n background: var(--heat-12); color: var(--heat); font-weight: 500;\n }\n .mp-main-h .tb-menu-empty {\n padding: 10px;\n font-size: 11.5px; color: var(--black-alpha-48);\n font-family: var(--font-mono); letter-spacing: .02em;\n text-align: center;\n }\n\n /* 批次被筛选隐藏 */\n .mp-result-batch[data-hidden=\"1\"] { display: none !important; }\n .mp-main-body {\n flex: 1; min-height: 0;\n display: grid;\n grid-template-columns: 320px 1fr;\n }\n @media (max-width: 1280px) {\n .mp-main-body { grid-template-columns: 300px 1fr; }\n }\n /* 内部 form / preview 不再带 border + radius, 由 layout 用 border-right 分隔 */\n .mp-main-body > .mp-form {\n border: 0; border-radius: 0;\n border-right: 1px solid var(--border-faint);\n background: var(--surface);\n }\n .mp-main-body > .mp-preview {\n border: 0; border-radius: 0;\n background: var(--background-base);\n }\n\n /* ─── 商品空间 (最左 · 单选 · 当前商品决定结果区批次) ─── */\n .mp-prod-space {\n background: var(--surface);\n border-right: 1px solid var(--border-faint);\n display: flex; flex-direction: column;\n min-height: 0;\n overflow: hidden;\n transition: opacity var(--t-base), transform var(--t-base);\n }\n .mp-layout.side-collapsed .mp-prod-space {\n opacity: 0;\n transform: translateX(-8px);\n pointer-events: none;\n }\n /* 顶部工具栏: 返回 + 折叠 (跟 image-optimize 视觉一致) */\n .mp-side-top {\n flex-shrink: 0;\n display: flex; align-items: center; gap: 8px;\n padding: 14px 14px 10px;\n border-bottom: 1px solid var(--border-faint);\n }\n .mp-side-top .back-pill {\n display: inline-flex; align-items: center; gap: 6px;\n height: 34px; padding: 0 13px 0 11px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-pill);\n color: var(--accent-black);\n font-size: 13px; font-weight: 500;\n font-family: inherit;\n cursor: pointer;\n transition: background var(--t-base), border-color var(--t-base), color var(--t-base);\n }\n .mp-side-top .back-pill:hover {\n background: var(--black-alpha-4);\n border-color: var(--black-alpha-24);\n color: var(--accent-black);\n }\n .mp-side-top .back-pill svg { width: 14px; height: 14px; }\n .mp-side-top .fold {\n margin-left: auto;\n width: 26px; height: 26px;\n background: transparent; border: 0; border-radius: var(--r-sm);\n display: grid; place-items: center;\n color: var(--black-alpha-48); cursor: pointer;\n transition: background var(--t-base), color var(--t-base);\n }\n .mp-side-top .fold:hover { background: var(--black-alpha-4); color: var(--accent-black); }\n .mp-side-top .fold svg { width: 14px; height: 14px; }\n .mp-ps-h {\n flex-shrink: 0;\n padding: 12px 14px 10px;\n }\n /* 商品列表 头部 (// 商品空间 + 新建商品 主 CTA · 右上显眼橙色) */\n .mp-list-h {\n flex-shrink: 0;\n display: flex; align-items: center; gap: 8px;\n padding: 4px 14px 10px;\n }\n .mp-list-h .mono {\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48); letter-spacing: .06em;\n text-transform: uppercase;\n }\n .mp-list-h .new-prod {\n margin-left: auto;\n height: 28px; padding: 0 12px 0 10px;\n display: inline-flex; align-items: center; gap: 6px;\n background: var(--heat); color: #fff;\n border: 1px solid var(--heat);\n border-radius: var(--r-sm);\n font-size: 12px; font-weight: 600;\n font-family: inherit;\n cursor: pointer;\n box-shadow:\n inset 0 -2px 4px rgba(250, 93, 25, 0.20),\n 0 1px 1px rgba(250, 93, 25, 0.12),\n 0 2px 4px rgba(250, 93, 25, 0.10);\n transition: filter var(--t-base), transform var(--t-fast), box-shadow var(--t-base);\n }\n .mp-list-h .new-prod:hover {\n filter: brightness(.96);\n box-shadow:\n inset 0 -2px 4px rgba(250, 93, 25, 0.20),\n 0 1px 1px rgba(250, 93, 25, 0.16),\n 0 4px 8px rgba(250, 93, 25, 0.20);\n }\n .mp-list-h .new-prod:active { transform: scale(.98); }\n .mp-list-h .new-prod svg { width: 12px; height: 12px; }\n /* 搜索框 (spec §10.3 · icon z-index:2) */\n .mp-ps-search {\n position: relative;\n height: 32px;\n }\n .mp-ps-search svg {\n position: absolute; left: 10px; top: 50%; transform: translateY(-50%);\n width: 13px; height: 13px;\n color: var(--black-alpha-48);\n z-index: 2;\n pointer-events: none;\n }\n .mp-ps-search input {\n width: 100%; height: 100%;\n padding: 0 10px 0 30px;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n font-size: 12.5px; color: var(--accent-black);\n font-family: inherit;\n outline: none;\n transition: border-color var(--t-base), background var(--t-base);\n }\n .mp-ps-search input:focus { border-color: var(--heat-40); background: var(--surface); }\n .mp-ps-search input::placeholder { color: var(--black-alpha-48); }\n /* 商品列表 (flex:1 占据中部所有剩余空间) */\n .mp-ps-list {\n flex: 1; min-height: 0;\n overflow-y: auto;\n padding: 4px 10px 10px;\n display: flex; flex-direction: column; gap: 4px;\n }\n .mp-ps-empty {\n padding: 24px 14px;\n text-align: center;\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48);\n letter-spacing: .04em;\n line-height: 1.7;\n }\n .mp-prod-item {\n display: flex; align-items: center; gap: 10px;\n padding: 8px;\n border: 1px solid transparent;\n border-radius: var(--r-sm);\n cursor: pointer;\n transition: background var(--t-base), border-color var(--t-base);\n }\n .mp-prod-item:hover { background: var(--black-alpha-4); }\n .mp-prod-item.active { background: var(--heat-12); border-color: var(--heat-20); }\n .mp-prod-item .thumb {\n flex-shrink: 0;\n width: 36px; height: 36px;\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n overflow: hidden;\n }\n .mp-prod-item.active .thumb { border-color: var(--heat); }\n .mp-prod-item .body { flex: 1; min-width: 0; }\n .mp-prod-item .nm {\n font-size: 12.5px;\n color: var(--accent-black); font-weight: 500;\n line-height: 1.3;\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n }\n .mp-prod-item.active .nm { color: var(--heat); font-weight: 600; }\n .mp-prod-item .sub {\n margin-top: 2px;\n font-family: var(--font-mono); font-size: 10px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n }\n /* 全部商品入口 (贴底) */\n .mp-ps-all {\n flex-shrink: 0;\n margin: 0 10px 12px;\n padding: 9px 12px;\n background: var(--background-lighter);\n border: 1px dashed var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--black-alpha-72);\n font-size: 12px;\n font-family: inherit;\n cursor: pointer;\n display: flex; align-items: center; gap: 8px;\n transition: border-color var(--t-base), color var(--t-base), background var(--t-base);\n }\n .mp-ps-all:hover { border-color: var(--heat); color: var(--heat); background: var(--heat-12); }\n .mp-ps-all .ct {\n margin-left: auto;\n color: var(--black-alpha-48);\n font-family: var(--font-mono); font-size: 10.5px;\n }\n .mp-ps-all:hover .ct { color: var(--heat); }\n .mp-ps-all svg { width: 12px; height: 12px; }\n\n /* ─── 左栏 · 表单 ─── */\n .mp-form {\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n overflow-y: auto;\n padding: 18px 20px;\n display: flex; flex-direction: column;\n }\n .mp-step { margin-bottom: 22px; }\n .mp-step:last-child { margin-bottom: 0; }\n .mp-step-h {\n display: flex; align-items: center; gap: 8px;\n margin-bottom: 12px;\n }\n .mp-step-h .num {\n width: 22px; height: 22px;\n border-radius: 50%;\n background: var(--heat-12); color: var(--heat);\n font-family: var(--font-mono); font-size: 11px;\n font-weight: 700;\n display: grid; place-items: center;\n flex-shrink: 0;\n }\n .mp-step-h .title { font-size: 14px; font-weight: 600; color: var(--accent-black); }\n .mp-step-h .right { margin-left: auto; font-size: 12px; color: var(--heat); cursor: pointer; }\n .mp-step-h .right:hover { text-decoration: underline; }\n\n /* 商品选择器 · 已选 chip 列表 */\n .prod-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 6px; }\n .prod-row {\n display: flex; align-items: center; gap: 10px;\n padding: 8px 10px;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n transition: background var(--t-base), border-color var(--t-base);\n }\n .prod-row .thumb {\n width: 28px; height: 28px;\n flex-shrink: 0;\n border-radius: var(--r-sm);\n }\n .prod-row .info { flex: 1; min-width: 0; }\n .prod-row .nm {\n font-size: 12.5px; color: var(--accent-black); font-weight: 500;\n overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n }\n .prod-row .meta {\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n margin-top: 2px;\n }\n .prod-row .x {\n width: 22px; height: 22px;\n display: grid; place-items: center;\n background: transparent; border: 0;\n border-radius: var(--r-sm);\n cursor: pointer;\n color: var(--black-alpha-48);\n flex-shrink: 0;\n transition: background var(--t-base), color var(--t-base);\n }\n .prod-row .x:hover { background: var(--black-alpha-8); color: var(--accent-crimson); }\n .prod-row .x svg { width: 12px; height: 12px; }\n .prod-row .swap {\n width: 22px; height: 22px;\n display: grid; place-items: center;\n background: transparent; border: 0;\n border-radius: var(--r-sm);\n cursor: pointer;\n color: var(--black-alpha-48);\n flex-shrink: 0;\n transition: background var(--t-base), color var(--t-base);\n }\n .prod-row .swap:hover { background: var(--heat-12); color: var(--heat); }\n .prod-row .swap svg { width: 13px; height: 13px; }\n .prod-empty {\n padding: 14px 10px;\n text-align: center;\n background: var(--background-lighter);\n border: 1px dashed var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--black-alpha-48);\n font-size: 12px;\n margin-bottom: 6px;\n }\n .prod-empty .mono {\n font-family: var(--font-mono);\n font-size: 10.5px;\n letter-spacing: .02em;\n margin-top: 4px;\n }\n .prod-add {\n display: flex; align-items: center; justify-content: center; gap: 6px;\n padding: 8px 10px;\n background: transparent;\n border: 1px dashed var(--heat-40);\n border-radius: var(--r-sm);\n cursor: pointer;\n font-size: 12.5px;\n color: var(--heat);\n font-family: inherit;\n transition: background var(--t-base);\n width: 100%;\n }\n .prod-add:hover { background: var(--heat-12); }\n .prod-add svg { width: 12px; height: 12px; }\n .prod-add[hidden] { display: none; }\n\n /* 商品库全屏弹窗 (复用 ml-modal-bg 结构) */\n .pl-modal-bg {\n position: fixed; inset: 0;\n background: var(--surface);\n z-index: 998;\n display: none;\n }\n .pl-modal-bg.show { display: flex; }\n .pl-modal {\n margin: 0;\n flex: 1;\n background: var(--surface);\n overflow: hidden;\n display: flex; flex-direction: column;\n }\n .pl-modal-h {\n display: flex; align-items: center; gap: 14px;\n padding: 14px 28px;\n border-bottom: 1px solid var(--border-faint);\n flex-shrink: 0;\n }\n .pl-modal-h h2 { font-size: 16px; font-weight: 600; }\n .pl-modal-h .ct {\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-48);\n letter-spacing: .02em;\n }\n .pl-modal-h .actions { margin-left: auto; display: flex; gap: 10px; }\n .pl-modal-body {\n flex: 1; min-height: 0;\n display: grid;\n grid-template-columns: 200px 1fr;\n }\n .pl-side {\n border-right: 1px solid var(--border-faint);\n padding: 18px 0;\n overflow-y: auto;\n }\n .pl-side .pl-side-h {\n padding: 0 20px 8px;\n font-family: var(--font-mono);\n font-size: 10.5px;\n color: var(--black-alpha-48);\n letter-spacing: .06em;\n }\n .pl-side .pl-side-item {\n display: flex; align-items: center; gap: 8px;\n padding: 9px 20px;\n cursor: pointer;\n color: var(--black-alpha-72);\n font-size: 13px;\n border-left: 3px solid transparent;\n transition: background var(--t-base), color var(--t-base);\n }\n .pl-side .pl-side-item:hover { background: var(--black-alpha-4); }\n .pl-side .pl-side-item.active {\n background: var(--heat-12);\n color: var(--accent-black);\n border-left-color: var(--heat);\n font-weight: 600;\n }\n .pl-side .pl-side-item .ct {\n margin-left: auto;\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-48);\n }\n .pl-main { overflow-y: auto; padding: 0; display: flex; flex-direction: column; }\n .pl-toolbar {\n padding: 14px 28px;\n border-bottom: 1px solid var(--border-faint);\n display: flex; align-items: center; gap: 12px;\n flex-shrink: 0;\n }\n .pl-toolbar .search {\n position: relative; flex: 1; max-width: 360px;\n }\n .pl-toolbar .search input {\n width: 100%; height: 32px;\n padding: 0 10px 0 32px;\n background: var(--background-lighter);\n border: 1px solid var(--black-alpha-12);\n border-radius: var(--r-sm);\n font-size: 12.5px;\n font-family: inherit;\n color: var(--accent-black);\n outline: none;\n }\n .pl-toolbar .search svg {\n position: absolute; left: 10px; top: 50%; transform: translateY(-50%);\n width: 14px; height: 14px; color: var(--black-alpha-48);\n }\n .pl-toolbar .btn-new {\n height: 32px;\n padding: 0 14px;\n display: inline-flex; align-items: center; gap: 6px;\n background: var(--surface);\n border: 1px solid var(--black-alpha-12);\n border-radius: var(--r-sm);\n color: var(--accent-black);\n font-family: inherit;\n font-size: 12.5px;\n cursor: pointer;\n margin-left: auto;\n transition: background var(--t-base), border-color var(--t-base);\n }\n .pl-toolbar .btn-new:hover { background: var(--background-lighter); border-color: var(--black-alpha-24); }\n .pl-toolbar .btn-new svg { width: 13px; height: 13px; }\n .pl-scroll {\n flex: 1; min-height: 0;\n overflow-y: auto;\n padding: 20px 28px 28px;\n }\n .pl-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));\n gap: 12px;\n }\n .pl-card {\n position: relative;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n padding: 10px;\n cursor: pointer;\n display: flex; flex-direction: column; gap: 6px;\n transition: background var(--t-base), border-color var(--t-base);\n }\n .pl-card:hover { background: var(--surface); }\n .pl-card.selected { border-color: var(--heat); background: var(--heat-12); }\n .pl-card .pl-thumb {\n aspect-ratio: 1;\n border-radius: var(--r-sm);\n }\n .pl-card .pl-name { font-size: 12.5px; font-weight: 500; color: var(--accent-black); }\n .pl-card .pl-meta {\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n }\n .pl-card .pl-check {\n position: absolute; top: 16px; right: 16px;\n width: 22px; height: 22px;\n background: rgba(255,255,255,.95);\n border: 1.5px solid var(--black-alpha-24);\n border-radius: 50%;\n display: grid; place-items: center;\n z-index: 2;\n color: var(--accent-white);\n }\n .pl-card .pl-check svg { width: 11px; height: 11px; opacity: 0; }\n .pl-card.selected .pl-check { background: var(--heat); border-color: var(--heat); }\n .pl-card.selected .pl-check svg { opacity: 1; }\n /* 卡片右上 actions: 编辑 + 删除 (hover 显示, check 左侧) */\n .pl-card .pl-card-actions {\n position: absolute;\n top: 14px; right: 44px;\n display: flex; gap: 4px;\n z-index: 2;\n opacity: 0;\n transition: opacity var(--t-base);\n }\n .pl-card:hover .pl-card-actions { opacity: 1; }\n .pl-card .pl-act {\n width: 26px; height: 26px;\n background: rgba(255,255,255,.95);\n border: 1px solid var(--black-alpha-12);\n border-radius: var(--r-sm);\n display: grid; place-items: center;\n cursor: pointer;\n color: var(--black-alpha-72);\n transition: color var(--t-base), border-color var(--t-base), background var(--t-base);\n }\n .pl-card .pl-act:hover { color: var(--heat); border-color: var(--heat); background: var(--surface); }\n .pl-card .pl-act.danger:hover { color: var(--accent-crimson); border-color: var(--accent-crimson); }\n .pl-card .pl-act svg { width: 12px; height: 12px; }\n .pl-modal-f {\n padding: 14px 28px;\n border-top: 1px solid var(--border-faint);\n display: flex; justify-content: flex-end; align-items: center; gap: 10px;\n flex-shrink: 0;\n }\n .pl-modal-f .summary {\n margin-right: auto;\n font-family: var(--font-mono);\n font-size: 12px;\n color: var(--black-alpha-56);\n letter-spacing: .02em;\n }\n .pl-modal-f .summary b { color: var(--heat); font-weight: 700; }\n\n /* 模特选择 · 矩形卡 多选 */\n .model-grid {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 8px;\n }\n .model-card {\n position: relative;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n padding: 8px;\n cursor: pointer;\n display: flex; flex-direction: column; gap: 6px;\n transition: background var(--t-base), border-color var(--t-base);\n }\n .model-card:hover { background: var(--surface); }\n .model-card.selected { border-color: var(--heat); background: var(--heat-12); }\n .model-card .m-thumb {\n aspect-ratio: 3/4;\n border-radius: var(--r-sm);\n cursor: pointer;\n position: relative;\n }\n .model-card .m-name { font-size: 12.5px; font-weight: 500; color: var(--accent-black); }\n .model-card .m-tag {\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n }\n .model-card .m-check {\n position: absolute; top: 14px; right: 14px;\n width: 22px; height: 22px;\n background: rgba(255,255,255,.95);\n border: 1.5px solid var(--black-alpha-24);\n border-radius: 50%;\n display: grid; place-items: center;\n color: var(--accent-white);\n z-index: 2;\n }\n .model-card .m-check svg { width: 11px; height: 11px; opacity: 0; }\n .model-card.selected .m-check { background: var(--heat); border-color: var(--heat); }\n .model-card.selected .m-check svg { opacity: 1; }\n .model-upload {\n grid-column: 1 / -1;\n aspect-ratio: 2/1;\n border: 1.5px dashed var(--heat-40);\n background: var(--heat-12);\n border-radius: var(--r-md);\n display: flex; align-items: center; justify-content: center; gap: 6px;\n cursor: pointer;\n color: var(--heat);\n font-size: 12.5px;\n transition: background var(--t-base);\n }\n .model-upload:hover { background: var(--heat-20); }\n .model-upload svg { width: 14px; height: 14px; }\n\n /* 单选 pill */\n .pill-row { display: flex; gap: 6px; }\n .pill-row .opt {\n flex: 1;\n height: 32px;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--black-alpha-72);\n font-size: 12.5px;\n cursor: pointer;\n font-family: inherit;\n transition: background var(--t-base), color var(--t-base), border-color var(--t-base);\n }\n .pill-row .opt:hover { color: var(--accent-black); }\n .pill-row .opt.active {\n background: var(--heat-12);\n color: var(--heat);\n border-color: var(--heat-40);\n font-weight: 600;\n }\n\n /* 设置 · 子项 */\n .mp-sub-h {\n font-size: 12px; color: var(--black-alpha-48);\n margin-bottom: 6px;\n font-family: var(--font-mono); letter-spacing: .02em;\n }\n .mp-sub { margin-bottom: 12px; }\n .mp-sub:last-child { margin-bottom: 0; }\n\n /* 左栏底部 · 立即生成 */\n .mp-cta {\n margin-top: auto;\n padding-top: 14px;\n }\n .mp-cta .btn-gen {\n width: 100%;\n justify-content: center;\n padding: 12px;\n font-size: 14px;\n font-weight: 600;\n }\n .mp-cta .btn-gen:disabled,\n .mp-cta .btn-gen.disabled {\n opacity: .45; cursor: not-allowed; pointer-events: none;\n }\n .mp-cta-hint {\n margin-top: 8px;\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--black-alpha-48);\n letter-spacing: .02em;\n text-align: center;\n line-height: 1.5;\n }\n\n /* ─── 右栏 · 预览 ─── */\n .mp-preview {\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n padding: 18px 22px;\n display: flex; flex-direction: column;\n overflow-y: auto;\n }\n /* prompt-style summary 卡片 (引号 icon + 灰底 + 右上 meta) */\n .mp-pv-h {\n position: relative;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n padding: 14px 18px 14px 44px;\n margin-bottom: 14px;\n }\n .mp-pv-h .quote-icon {\n position: absolute;\n top: 13px; left: 16px;\n width: 18px; height: 18px;\n color: var(--black-alpha-24);\n }\n .mp-pv-h .pv-meta {\n float: right;\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--black-alpha-48);\n letter-spacing: .04em;\n line-height: 1.5;\n }\n .mp-pv-h .pv-meta b { color: var(--accent-black); font-weight: 600; }\n .mp-pv-h .pv-line {\n font-size: 13px;\n color: var(--accent-black);\n line-height: 1.6;\n display: flex; align-items: center;\n }\n .mp-pv-h .pv-line .k {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--black-alpha-48);\n letter-spacing: .04em;\n margin-right: 8px;\n min-width: 36px;\n }\n .mp-pv-h .pv-line .v {\n font-weight: 500;\n }\n .mp-pv-h .pv-line .swap {\n margin-left: 10px;\n font-size: 11.5px; color: var(--heat);\n cursor: pointer;\n }\n .mp-pv-h .pv-line .swap:hover { text-decoration: underline; }\n\n .mp-pv-grid {\n flex: 1;\n display: flex; flex-direction: column;\n gap: 18px;\n overflow-y: auto;\n padding-right: 4px;\n align-content: start;\n }\n .mp-result-batch { display: flex; flex-direction: column; gap: 10px; }\n .mp-batch-head {\n display: flex; align-items: center; gap: 8px;\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-56); letter-spacing: .04em;\n }\n .mp-batch-head .lab {\n font-family: var(--font-mono); font-size: 10.5px;\n padding: 2px 8px; border-radius: var(--r-pill);\n background: var(--heat-12); color: var(--heat); font-weight: 600;\n }\n .mp-batch-head .lab.gen { background: var(--background-lighter); color: var(--black-alpha-56); }\n .mp-batch-head .lab.rerun { background: var(--heat-12); color: var(--heat); }\n .mp-batch-head .sep { color: var(--black-alpha-32); }\n .mp-result-grid {\n display: grid;\n grid-template-columns: repeat(4, 1fr);\n gap: 12px;\n align-content: start;\n }\n @media (max-width: 1400px) {\n .mp-result-grid { gap: 10px; }\n }\n /* 结果卡片 · 与图片创作 .io-cell 视觉一致 */\n .mp-result {\n position: relative;\n aspect-ratio: 3/4;\n border-radius: var(--r-md);\n overflow: hidden;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n cursor: pointer;\n transition: border-color var(--t-base);\n }\n .mp-result:hover { border-color: var(--black-alpha-32); }\n .mp-result .mp-r-thumb {\n position: absolute; inset: 0;\n }\n .mp-result .mp-r-thumb .ph-frame {\n position: absolute; inset: 0;\n display: grid; place-items: center;\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-32); letter-spacing: .02em;\n background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);\n }\n .mp-result.gen .mp-r-thumb .ph-frame { animation: mp-pulse 1.4s ease-in-out infinite; }\n @keyframes mp-pulse {\n 0%, 100% { opacity: 1; }\n 50% { opacity: .55; }\n }\n .mp-result.err { border-color: var(--accent-crimson, #c43d3d); }\n .mp-result.err .mp-r-thumb .ph-frame {\n color: var(--accent-crimson, #c43d3d);\n background: rgba(196, 61, 61, .05);\n }\n /* 右上 hover 操作组 · 同 spec §4.18 .gen-image-actions (整体容器 + 透明按钮) */\n .mp-result .cell-ops {\n position: absolute; top: 8px; right: 8px;\n display: flex; gap: 2px;\n padding: 2px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n box-shadow: 0 2px 8px rgba(0,0,0,.08);\n opacity: 0;\n transition: opacity var(--t-base);\n z-index: 2;\n }\n .mp-result:hover .cell-ops { opacity: 1; }\n .mp-result .cell-ops button {\n width: 28px; height: 28px;\n background: transparent;\n border: 0;\n border-radius: 6px;\n color: var(--black-alpha-56);\n cursor: pointer;\n display: grid; place-items: center;\n transition: background var(--t-base), color var(--t-base);\n }\n .mp-result .cell-ops button:hover {\n background: var(--black-alpha-4);\n color: var(--accent-black);\n }\n .mp-result .cell-ops button svg { width: 14px; height: 14px; }\n /* 中央采用反馈 · 同 spec §4.18 .gen-image-feedback */\n .mp-result .cell-feedback {\n position: absolute; inset: 0;\n display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px;\n background: rgba(38, 38, 38, .88);\n color: var(--accent-white);\n border-radius: var(--r-md);\n opacity: 0;\n pointer-events: none;\n transition: opacity .2s var(--t-base, ease);\n z-index: 3;\n }\n .mp-result.show-feedback .cell-feedback { opacity: 1; }\n .mp-result .cell-feedback svg { width: 20px; height: 20px; }\n .mp-result .cell-feedback span { font-size: 12.5px; font-weight: 500; letter-spacing: .02em; }\n /* 更多 · 下拉气泡 */\n .mp-result .cell-more-wrap { position: relative; }\n .mp-result .cell-more-menu {\n position: absolute; top: calc(100% + 4px); right: 0;\n min-width: 132px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n box-shadow: 0 6px 24px rgba(0,0,0,.10);\n padding: 4px;\n display: none;\n z-index: 12;\n }\n .mp-result .cell-more-wrap.open .cell-more-menu { display: block; }\n .mp-result .cell-more-menu button {\n width: 100%;\n display: inline-flex !important; align-items: center; gap: 8px;\n padding: 7px 10px !important;\n background: transparent !important;\n border: 0 !important; border-radius: var(--r-sm) !important;\n font-size: 12.5px;\n color: var(--accent-black) !important;\n font-family: inherit;\n text-align: left;\n cursor: pointer;\n backdrop-filter: none !important;\n height: auto !important;\n justify-content: flex-start !important;\n }\n .mp-result .cell-more-menu button:hover {\n background: var(--background-lighter) !important;\n color: var(--heat) !important;\n }\n .mp-result .cell-more-menu button.danger:hover {\n color: var(--accent-crimson) !important;\n background: var(--crimson-bg, #fdebea) !important;\n }\n .mp-result .cell-more-menu button svg { width: 13px !important; height: 13px !important; }\n /* 已采用角标 (保留 · 仅状态指示, hover overlay 仍可见) */\n .mp-result .adopt-badge {\n position: absolute; top: 8px; left: 8px;\n background: var(--accent-forest, #42c366);\n color: #fff;\n font-family: var(--font-mono); font-size: 10.5px; font-weight: 600;\n letter-spacing: .04em;\n padding: 2px 8px;\n border-radius: var(--r-pill);\n z-index: 3;\n display: none;\n }\n .mp-result.adopted .adopt-badge { display: inline-block; }\n\n /* 每个批次下方的批量操作 (胶囊按钮组 · 左对齐) */\n .mp-pv-batch {\n margin-top: 4px;\n display: flex; gap: 10px; align-items: center; justify-content: flex-start;\n }\n .mp-pv-batch[hidden] { display: none; }\n .mp-result-batch .mp-pv-batch.batch-foot { margin-top: 0; }\n\n /* 预览区空态 (新任务且未生成) */\n .mp-pv-empty {\n flex: 1;\n display: flex; flex-direction: column;\n align-items: center; justify-content: center;\n text-align: center;\n padding: 40px 24px;\n gap: 6px;\n }\n .mp-pv-empty[hidden] { display: none; }\n .mp-pv-empty .mono {\n font-family: var(--font-mono);\n font-size: 10.5px;\n color: var(--black-alpha-48);\n letter-spacing: .06em;\n margin-bottom: 4px;\n }\n .mp-pv-empty .title {\n font-size: 14px;\n font-weight: 600;\n color: var(--accent-black);\n }\n .mp-pv-empty .hint {\n font-size: 12.5px;\n color: var(--black-alpha-48);\n line-height: 1.6;\n max-width: 320px;\n }\n .mp-pv-empty .hint b { color: var(--heat); font-weight: 600; }\n /* 预览区 hidden 时收起所有内容元素 */\n #pv-summary[hidden], #pv-grid[hidden], #pv-foot[hidden] { display: none; }\n .mp-pv-batch .summary {\n margin-right: auto;\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n }\n .mp-pv-batch .summary b { color: var(--heat); font-weight: 700; }\n /* 底栏按钮 · 同 spec §4.18 + image-optimize .io-msg-ops button */\n .mp-pv-batch .pill-btn {\n height: 30px;\n padding: 0 12px;\n display: inline-flex; align-items: center; gap: 6px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n color: var(--accent-black);\n font-family: inherit; font-size: 12.5px;\n cursor: pointer;\n transition: background var(--t-base), border-color var(--t-base), color var(--t-base);\n }\n .mp-pv-batch .pill-btn:hover {\n border-color: var(--heat-20); color: var(--heat); background: var(--heat-12);\n }\n .mp-pv-batch .pill-btn svg { width: 13px; height: 13px; }\n .mp-pv-batch .pill-btn.icon { width: 30px; padding: 0; justify-content: center; }\n /* 批次更多气泡 (全部加入资产库 / 删除该批结果) */\n .mp-pv-batch .batch-more-wrap { position: relative; display: inline-flex; }\n .mp-pv-batch .batch-more-menu {\n position: absolute; bottom: calc(100% + 6px); right: 0;\n min-width: 168px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n box-shadow: 0 6px 24px rgba(0,0,0,.10);\n padding: 4px;\n display: none;\n z-index: 12;\n }\n .mp-pv-batch .batch-more-wrap.open .batch-more-menu { display: block; }\n .mp-pv-batch .batch-more-menu button {\n width: 100%;\n display: inline-flex !important; align-items: center; gap: 8px;\n height: auto !important;\n padding: 7px 10px !important;\n background: transparent !important;\n border: 0 !important; border-radius: var(--r-sm) !important;\n font-size: 12.5px;\n color: var(--accent-black) !important;\n text-align: left;\n justify-content: flex-start !important;\n cursor: pointer; font-family: inherit;\n }\n .mp-pv-batch .batch-more-menu button:hover {\n background: var(--background-lighter) !important;\n color: var(--heat) !important;\n }\n .mp-pv-batch .batch-more-menu button.danger:hover {\n color: var(--accent-crimson) !important;\n background: var(--crimson-bg, #fdebea) !important;\n }\n .mp-pv-batch .batch-more-menu button svg { width: 13px !important; height: 13px !important; flex-shrink: 0; }\n\n .mp-pv-foot {\n margin-top: 14px;\n padding-top: 12px;\n border-top: 1px solid var(--border-faint);\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--black-alpha-56);\n letter-spacing: .02em;\n line-height: 1.6;\n }\n .mp-pv-foot a { color: var(--heat); }\n\n @media (max-width: 1100px) {\n .mp-layout { grid-template-columns: 1fr; }\n }\n\n /* ─── 模特库全屏弹窗 (无遮罩,自适应铺满) ─── */\n .ml-modal-bg {\n position: fixed; inset: 0;\n background: var(--surface);\n z-index: 999;\n display: none;\n }\n .ml-modal-bg.show { display: flex; }\n .ml-modal {\n margin: 0;\n flex: 1;\n background: var(--surface);\n border-radius: 0;\n overflow: hidden;\n display: flex; flex-direction: column;\n }\n .ml-modal-h {\n display: flex; align-items: center;\n padding: 14px 28px;\n border-bottom: 1px solid var(--border-faint);\n flex-shrink: 0;\n }\n .ml-modal-h h2 { font-size: 16px; font-weight: 600; }\n .ml-modal-h .x {\n margin-left: auto;\n width: 32px; height: 32px;\n display: grid; place-items: center;\n background: transparent;\n border: 0; border-radius: var(--r-sm);\n cursor: pointer;\n color: var(--black-alpha-56);\n transition: background var(--t-base), color var(--t-base);\n }\n .ml-modal-h .x:hover { background: var(--black-alpha-8); color: var(--accent-black); }\n .ml-modal-h .x svg { width: 16px; height: 16px; }\n .ml-modal-body {\n flex: 1; min-height: 0;\n display: grid;\n grid-template-columns: 200px 1fr;\n }\n .ml-side {\n border-right: 1px solid var(--border-faint);\n padding: 18px 0;\n overflow-y: auto;\n }\n .ml-side .ml-side-h {\n padding: 0 20px 8px;\n font-family: var(--font-mono);\n font-size: 10.5px;\n color: var(--black-alpha-48);\n letter-spacing: .06em;\n }\n .ml-side .ml-side-item {\n display: flex; align-items: center; gap: 8px;\n padding: 9px 20px;\n cursor: pointer;\n color: var(--black-alpha-72);\n font-size: 13px;\n border-left: 3px solid transparent;\n transition: background var(--t-base), color var(--t-base);\n }\n .ml-side .ml-side-item:hover { background: var(--black-alpha-4); }\n .ml-side .ml-side-item.active {\n background: var(--heat-12);\n color: var(--accent-black);\n border-left-color: var(--heat);\n font-weight: 600;\n }\n .ml-side .ml-side-item .ct {\n margin-left: auto;\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-48);\n }\n .ml-main {\n overflow-y: auto;\n padding: 0;\n display: flex; flex-direction: column;\n position: relative; /* anchor for .ml-canvas overlay */\n }\n\n /* 添加模特 · 工作台画布 · 覆盖 .ml-main 的整个区域,自带展开/收起动效 */\n .ml-canvas {\n position: absolute; inset: 0;\n z-index: 10;\n background: var(--background-base);\n display: flex; flex-direction: column;\n opacity: 0; visibility: hidden;\n transform: scale(.94);\n transform-origin: 32px 80px; /* 从左上「添加模特」卡片的视觉中心展开 */\n transition: opacity .28s ease, transform .32s cubic-bezier(.18,.72,.28,1), visibility .32s;\n pointer-events: none;\n }\n .ml-canvas.show {\n opacity: 1; visibility: visible;\n transform: scale(1);\n pointer-events: auto;\n }\n .ml-canvas-h {\n display: flex; align-items: center; gap: 12px;\n padding: 14px 28px;\n border-bottom: 1px solid var(--border-faint);\n flex-shrink: 0;\n background: var(--surface);\n }\n .ml-canvas-h .back-btn {\n display: inline-flex; align-items: center; gap: 4px;\n height: 28px; padding: 0 10px;\n background: transparent; border: 1px solid var(--border-faint);\n border-radius: var(--r-sm); color: var(--black-alpha-72);\n font-family: inherit; font-size: 12px; cursor: pointer;\n transition: border-color var(--t-base), color var(--t-base), background var(--t-base);\n }\n .ml-canvas-h .back-btn:hover { border-color: var(--heat-40); color: var(--heat); background: var(--heat-12); }\n .ml-canvas-h .back-btn svg { width: 12px; height: 12px; }\n .ml-canvas-h h3 { font-size: 15px; font-weight: 600; color: var(--accent-black); margin: 0; }\n .ml-canvas-h .mono { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }\n .ml-canvas-h .x {\n width: 28px; height: 28px; display: grid; place-items: center;\n background: transparent; border: none;\n border-radius: var(--r-sm); color: var(--black-alpha-56); cursor: pointer;\n transition: background var(--t-base), color var(--t-base);\n }\n .ml-canvas-h .x:hover { background: var(--background-lighter); color: var(--accent-black); }\n .ml-canvas-h .x svg { width: 14px; height: 14px; }\n\n /* 画布双栏:左 AI 生成(主视觉),右 本地上传(副视觉) */\n .ml-canvas-body {\n flex: 1; min-height: 0;\n display: grid; grid-template-columns: 2fr 1fr;\n overflow: hidden;\n }\n .mc-ai { position: relative; display: flex; flex-direction: column; min-height: 0; background: var(--background-base); }\n .mc-up { position: relative; display: flex; flex-direction: column; min-height: 0; background: var(--surface); border-left: 1px solid var(--border-faint); }\n\n /* ── 左:AI 生成 · 完全照搬 image-optimize .io-* 输入对话框样式 ── */\n .mc-stream {\n flex: 1; min-height: 0;\n overflow-y: auto;\n /* 左右 28px · 与 .ml-canvas-h(padding: 14px 28px)版心对齐 */\n padding: 28px 28px 220px;\n background: var(--background-base);\n }\n .mc-stream-inner {\n /* 取消 max-width 限制 · 让结果网格在固定画布宽度下吃满可用空间,并与头部 28px 版心对齐 */\n width: 100%; margin: 0 auto;\n display: flex; flex-direction: column; gap: 28px;\n }\n\n /* 空态 · 同 .io-empty */\n .mc-empty {\n flex: 1; min-height: 100%;\n display: flex; flex-direction: column;\n align-items: center; justify-content: center;\n gap: 16px;\n padding: 40px;\n color: var(--black-alpha-56);\n text-align: center;\n }\n .mc-empty .badge {\n font-family: var(--font-mono); font-size: 11px;\n letter-spacing: .08em; color: var(--black-alpha-48);\n text-transform: uppercase;\n }\n .mc-empty h2 {\n font-size: 22px; font-weight: 600;\n color: var(--accent-black);\n letter-spacing: -.015em;\n margin: 0;\n }\n .mc-empty p { font-size: 13px; max-width: 460px; line-height: 1.6; margin: 0; }\n .mc-empty .ic {\n width: 64px; height: 64px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n display: grid; place-items: center;\n color: var(--heat);\n }\n .mc-empty .ic svg { width: 28px; height: 28px; }\n .mc-empty .examples {\n margin-top: 10px;\n display: flex; flex-wrap: wrap; gap: 8px; justify-content: center;\n max-width: 720px;\n }\n .mc-empty .examples .ex {\n padding: 6px 12px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-pill);\n font-size: 12px;\n color: var(--black-alpha-72);\n cursor: pointer;\n transition: border-color var(--t-base), color var(--t-base), background var(--t-base);\n }\n .mc-empty .examples .ex:hover { border-color: var(--heat-20); color: var(--heat); background: var(--heat-12); }\n\n /* 单条对话气泡 · 同 .io-msg(去掉 padding-left,让消息贴齐外层 28px 版心) */\n .mc-msg { display: flex; flex-direction: column; gap: 14px; }\n .mc-msg-prompt { display: flex; align-items: flex-start; gap: 12px; }\n .mc-msg-prompt .quote {\n flex-shrink: 0; width: 28px; height: 28px;\n border-radius: var(--r-sm);\n background: var(--surface); border: 1px solid var(--border-faint);\n color: var(--heat); display: grid; place-items: center;\n font-family: var(--font-mono); font-size: 13px;\n }\n .mc-msg-prompt .quote svg { width: 13px; height: 13px; }\n .mc-msg-prompt .pt { flex: 1; min-width: 0; padding-top: 4px; }\n .mc-msg-prompt .pt-text {\n font-size: 14px; color: var(--accent-black);\n line-height: 1.55; word-break: break-word;\n }\n .mc-msg-prompt .pt-tags {\n margin-top: 8px;\n display: flex; flex-wrap: wrap; gap: 6px;\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n align-items: center;\n }\n .mc-msg-prompt .pt-tags .meta-chip {\n padding: 2px 8px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n }\n .mc-msg-prompt .pt-tags .sep { color: var(--black-alpha-24); }\n .mc-msg-grid {\n display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;\n }\n @media (max-width: 1280px) { .mc-msg-grid { grid-template-columns: repeat(3, 1fr); } }\n .mc-cell {\n position: relative;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n overflow: hidden;\n aspect-ratio: 3/4;\n }\n .mc-cell .ph-frame {\n position: absolute; inset: 0;\n display: grid; place-items: center;\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-32); letter-spacing: .02em;\n background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);\n }\n .mc-cell.gen .ph-frame { animation: mc-pulse 1.4s ease-in-out infinite; }\n @keyframes mc-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .55; } }\n .mc-cell:hover { border-color: var(--black-alpha-32); }\n .mc-cell.err { border-color: var(--accent-crimson, #c43d3d); }\n .mc-cell.err .ph-frame { color: var(--accent-crimson, #c43d3d); background: rgba(196, 61, 61, .05); }\n\n /* 单图右上 hover 操作组 · 同 .io-cell .cell-ops */\n .mc-cell .cell-ops {\n position: absolute; top: 6px; right: 6px;\n display: flex; gap: 4px;\n opacity: 0;\n transition: opacity var(--t-base);\n z-index: 2;\n }\n .mc-cell:hover .cell-ops { opacity: 1; }\n .mc-cell .cell-ops button {\n width: 26px; height: 26px;\n background: rgba(255, 255, 255, .92);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--accent-black);\n cursor: pointer;\n display: grid; place-items: center;\n backdrop-filter: blur(4px);\n transition: border-color var(--t-base), color var(--t-base);\n }\n .mc-cell .cell-ops button:hover { border-color: var(--heat); color: var(--heat); }\n .mc-cell .cell-ops button svg { width: 12px; height: 12px; }\n .mc-cell .cell-more-wrap { position: relative; }\n .mc-cell .cell-more-menu {\n position: absolute; top: calc(100% + 4px); right: 0;\n min-width: 140px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n box-shadow: 0 6px 24px rgba(0,0,0,.10);\n padding: 4px;\n display: none;\n z-index: 12;\n }\n .mc-cell .cell-more-wrap.open .cell-more-menu { display: block; }\n .mc-cell .cell-more-menu button {\n width: 100%;\n display: inline-flex !important; align-items: center; gap: 8px;\n height: auto !important;\n padding: 7px 10px !important;\n background: transparent !important;\n border: 0 !important; border-radius: var(--r-sm) !important;\n font-size: 12.5px;\n color: var(--accent-black) !important;\n font-family: inherit;\n text-align: left;\n cursor: pointer;\n backdrop-filter: none !important;\n justify-content: flex-start !important;\n }\n .mc-cell .cell-more-menu button:hover { background: var(--background-lighter) !important; color: var(--heat) !important; }\n .mc-cell .cell-more-menu button.danger:hover { color: var(--accent-crimson) !important; background: var(--crimson-bg, #fdebea) !important; }\n .mc-cell .cell-more-menu button svg { width: 13px !important; height: 13px !important; flex-shrink: 0; }\n\n /* 操作行 · 同 .io-msg-ops(去掉 padding-left,与版心对齐) */\n .mc-msg-ops { display: flex; gap: 8px; }\n .mc-msg-ops button {\n display: inline-flex; align-items: center; gap: 6px;\n height: 30px; padding: 0 12px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n font-size: 12.5px;\n color: var(--accent-black);\n font-family: inherit;\n cursor: pointer;\n transition: border-color var(--t-base), color var(--t-base), background var(--t-base);\n }\n .mc-msg-ops button:hover { border-color: var(--heat-20); color: var(--heat); background: var(--heat-12); }\n .mc-msg-ops button.icon { width: 30px; padding: 0; justify-content: center; }\n .mc-msg-ops button svg { width: 13px; height: 13px; }\n /* 批次更多气泡 · 同 .io-msg-ops .msg-more-wrap */\n .mc-msg-ops .msg-more-wrap { position: relative; }\n .mc-msg-ops .msg-more-menu {\n position: absolute; bottom: calc(100% + 6px); left: 0;\n min-width: 168px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n box-shadow: 0 6px 24px rgba(0,0,0,.10);\n padding: 4px;\n display: none;\n z-index: 12;\n }\n .mc-msg-ops .msg-more-wrap.open .msg-more-menu { display: block; }\n .mc-msg-ops .msg-more-menu button {\n width: 100%;\n display: inline-flex !important; align-items: center; gap: 8px;\n height: auto !important;\n padding: 7px 10px !important;\n background: transparent !important;\n border: 0 !important; border-radius: var(--r-sm) !important;\n font-size: 12.5px;\n color: var(--accent-black) !important;\n text-align: left;\n justify-content: flex-start !important;\n cursor: pointer; font-family: inherit;\n }\n .mc-msg-ops .msg-more-menu button:hover { background: var(--background-lighter) !important; color: var(--heat) !important; }\n .mc-msg-ops .msg-more-menu button.danger:hover { color: var(--accent-crimson) !important; background: var(--crimson-bg, #fdebea) !important; }\n .mc-msg-ops .msg-more-menu button svg { width: 13px !important; height: 13px !important; flex-shrink: 0; }\n\n /* 底部输入栏 · 完全照搬 .io-input 各项尺寸 */\n .mc-input-wrap {\n position: absolute; left: 0; right: 0; bottom: 0;\n padding: 14px 28px 22px;\n background: linear-gradient(to bottom, transparent 0, var(--background-base) 24px);\n z-index: 5;\n }\n .mc-input {\n max-width: 720px; margin: 0 auto;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: 18px;\n padding: 12px 14px 10px;\n display: flex; flex-direction: column; gap: 8px;\n box-shadow: 0 6px 24px rgba(0,0,0,.06);\n transition: border-color var(--t-base);\n }\n .mc-input:focus-within { border-color: var(--heat-40); }\n\n /* 上行 · 加号 + 参考图(同一 flex 行,均 64×64,refs 容器用 display:contents 让子项直接参与上行排列) */\n .mc-input-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }\n .mc-input-top .add-btn {\n flex-shrink: 0; width: 64px; height: 64px;\n background: var(--background-lighter);\n border: 1px dashed var(--border-faint);\n border-radius: var(--r-md);\n display: grid; place-items: center;\n color: var(--black-alpha-56); cursor: pointer;\n transition: border-color var(--t-base), color var(--t-base), background var(--t-base);\n }\n .mc-input-top .add-btn:hover { border-color: var(--heat-40); color: var(--heat); background: var(--heat-12); }\n .mc-input-top .add-btn svg { width: 22px; height: 22px; }\n\n .mc-input-refs { display: contents; }\n .mc-input-ref {\n position: relative; width: 64px; height: 64px;\n border-radius: var(--r-md); overflow: hidden;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n flex-shrink: 0;\n }\n .mc-input-ref img { width: 100%; height: 100%; object-fit: cover; }\n .mc-input-ref .x {\n position: absolute; top: 3px; right: 3px;\n width: 18px; height: 18px;\n background: rgba(0,0,0,.7); color: var(--accent-white);\n border: 0; border-radius: 50%;\n display: grid; place-items: center;\n cursor: pointer;\n }\n .mc-input-ref .x svg { width: 10px; height: 10px; }\n\n /* 中行 · textarea 满宽 · 直接作为 .mc-input 的子级,自成一行 */\n .mc-input textarea#mc-input-text {\n width: 100%;\n border: 0; outline: 0; resize: none;\n background: transparent;\n font-family: inherit;\n font-size: 14px;\n line-height: 1.5;\n color: var(--accent-black);\n min-height: 44px; max-height: 220px;\n padding: 4px 2px;\n }\n .mc-input textarea#mc-input-text::placeholder { color: var(--black-alpha-48); }\n\n /* 下行 · 参数胶囊 + 右 meta + 发送按钮(32×32 放在最右,margin-left:8) */\n .mc-input-bottom { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }\n .mc-input-bottom .param {\n position: relative; display: inline-flex; align-items: center; gap: 4px;\n height: 26px; padding: 0 9px;\n background: var(--background-lighter);\n border: 1px solid transparent;\n border-radius: var(--r-pill);\n font-size: 11.5px; color: var(--black-alpha-72);\n cursor: pointer;\n transition: border-color var(--t-base), color var(--t-base), background var(--t-base);\n }\n .mc-input-bottom .param:hover { background: var(--surface); border-color: var(--border-faint); }\n .mc-input-bottom .param .lbl-mono { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; margin-right: 1px; }\n .mc-input-bottom .param svg { width: 10px; height: 10px; opacity: .6; }\n .mc-input-bottom .right-meta { margin-left: auto; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .mc-input-bottom .right-meta .val { color: var(--accent-black); }\n .mc-input .send-btn {\n flex-shrink: 0; width: 32px; height: 32px;\n background: var(--heat); color: var(--accent-white);\n border: 0; border-radius: var(--r-md); cursor: pointer;\n display: grid; place-items: center;\n transition: opacity var(--t-base), filter var(--t-base);\n margin-left: 8px;\n }\n .mc-input .send-btn:hover { filter: brightness(1.05); }\n .mc-input .send-btn:disabled { opacity: .4; cursor: not-allowed; }\n .mc-input .send-btn svg { width: 15px; height: 15px; }\n\n /* ── 右:副视觉 · 顶部 tab 切换(AI 生成 / 本地上传)+ 3 模块(姓名/立绘/三视图) ── */\n .mc-up-tabs { display: flex; border-bottom: 1px solid var(--border-faint); flex-shrink: 0; background: var(--surface); }\n .mc-up-tab {\n flex: 1; height: 44px;\n background: transparent; border: 0;\n border-bottom: 2px solid transparent;\n font-family: inherit; font-size: 13px; font-weight: 500;\n color: var(--black-alpha-56); cursor: pointer;\n transition: color var(--t-base), border-color var(--t-base), background var(--t-base);\n }\n .mc-up-tab:hover { color: var(--accent-black); background: var(--background-lighter); }\n .mc-up-tab.active { color: var(--heat); border-bottom-color: var(--heat); font-weight: 600; background: var(--surface); }\n\n .mc-up-body { flex: 1; min-height: 0; padding: 18px 20px 14px; display: flex; flex-direction: column; gap: 18px; overflow-y: auto; }\n .mc-up-section { display: flex; flex-direction: column; gap: 8px; }\n .mc-up-sec-h { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }\n\n /* 模特姓名 输入 */\n .mc-up-name {\n width: 100%; height: 36px;\n padding: 0 12px;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n font-family: inherit; font-size: 13.5px; color: var(--accent-black);\n outline: none;\n transition: border-color var(--t-base), background var(--t-base);\n }\n .mc-up-name:focus { border-color: var(--heat-40); background: var(--surface); }\n .mc-up-name::placeholder { color: var(--black-alpha-40); }\n\n /* 立绘模块 · AI 选中态 */\n .mc-portrait-ai .empty {\n aspect-ratio: 3/4; max-height: 220px;\n border: 1.5px dashed var(--black-alpha-24);\n border-radius: var(--r-md);\n display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px;\n background: var(--background-lighter);\n text-align: center; padding: 14px;\n }\n .mc-portrait-ai .empty[hidden] { display: none; }\n .mc-portrait-ai .picked[hidden] { display: none; }\n .mc-portrait-ai .empty .ic { width: 38px; height: 38px; border-radius: 50%; background: var(--surface); border: 1px solid var(--border-faint); display: grid; place-items: center; color: var(--black-alpha-48); }\n .mc-portrait-ai .empty .ic svg { width: 16px; height: 16px; }\n .mc-portrait-ai .empty .desc { font-size: 12.5px; color: var(--black-alpha-72); line-height: 1.55; }\n .mc-portrait-ai .empty .mono { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; }\n .mc-portrait-ai .picked {\n position: relative; aspect-ratio: 3/4; max-height: 280px;\n background: var(--background-lighter); border: 1.5px solid var(--heat);\n border-radius: var(--r-md); overflow: hidden;\n }\n .mc-portrait-ai .picked .ph-frame {\n position: absolute; inset: 0; display: grid; place-items: center;\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-32); letter-spacing: .02em;\n background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);\n }\n .mc-portrait-ai .picked .ops { position: absolute; top: 8px; right: 8px; display: flex; gap: 4px; }\n .mc-portrait-ai .picked .ops button {\n width: 26px; height: 26px;\n background: rgba(255,255,255,.92); border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n display: grid; place-items: center;\n color: var(--accent-black); cursor: pointer;\n transition: border-color var(--t-base), color var(--t-base);\n }\n .mc-portrait-ai .picked .ops button:hover { border-color: var(--heat); color: var(--heat); }\n .mc-portrait-ai .picked .ops button svg { width: 12px; height: 12px; }\n .mc-portrait-ai .picked .badge {\n position: absolute; top: 8px; left: 8px;\n background: var(--heat); color: var(--accent-white);\n padding: 2px 7px; border-radius: var(--r-sm);\n font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .04em;\n }\n\n /* 立绘模块 · 本地上传(多张) */\n .mc-portrait-local .drop {\n border: 1.5px dashed var(--black-alpha-24);\n border-radius: var(--r-md);\n padding: 20px 14px;\n display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px;\n cursor: pointer;\n background: var(--background-lighter);\n transition: border-color var(--t-base), background var(--t-base);\n text-align: center;\n }\n .mc-portrait-local .drop:hover, .mc-portrait-local .drop.dragover { border-color: var(--heat); background: var(--heat-12); }\n .mc-portrait-local .drop .ic {\n width: 32px; height: 32px;\n background: var(--heat); color: var(--accent-white);\n border-radius: 50%; display: grid; place-items: center;\n }\n .mc-portrait-local .drop .ic svg { width: 14px; height: 14px; }\n .mc-portrait-local .drop .t { font-size: 12.5px; font-weight: 600; color: var(--accent-black); }\n .mc-portrait-local .drop .d { font-size: 11px; color: var(--black-alpha-48); }\n .mc-portrait-local .list-h { display: flex; align-items: center; gap: 4px; margin-top: 6px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; }\n .mc-portrait-local .list-h .ct { color: var(--accent-black); font-weight: 600; }\n .mc-portrait-local .list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }\n .mc-portrait-local .list:empty { display: none; }\n .mc-portrait-local .thumb {\n position: relative; aspect-ratio: 3/4;\n border-radius: var(--r-sm); overflow: hidden;\n background: var(--background-lighter); border: 1px solid var(--border-faint);\n }\n .mc-portrait-local .thumb img { width: 100%; height: 100%; object-fit: cover; }\n .mc-portrait-local .thumb .x {\n position: absolute; top: 4px; right: 4px;\n width: 20px; height: 20px;\n background: rgba(0,0,0,.7); color: var(--accent-white);\n border: 0; border-radius: 50%;\n display: grid; place-items: center; cursor: pointer;\n }\n .mc-portrait-local .thumb .x svg { width: 10px; height: 10px; }\n\n /* 三视图模块 · 始终展示 16:9 占位 · 空态时生成按钮居中覆盖 · 有版本时按钮消失,显示重跑 + 历史 */\n .mc-triview .result-wrap { display: flex; flex-direction: column; gap: 8px; }\n .mc-triview .result {\n position: relative; aspect-ratio: 16/9;\n background: var(--background-lighter); border: 1.5px solid var(--border-faint);\n border-radius: var(--r-md); overflow: hidden;\n transition: border-color var(--t-base);\n }\n .mc-triview.has-result .result { border-color: var(--heat); }\n .mc-triview .result .ph-frame {\n position: absolute; inset: 0; display: grid; place-items: center;\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-32); letter-spacing: .02em;\n background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);\n pointer-events: none;\n }\n .mc-triview .result.gen .ph-frame { animation: mc-pulse 1.4s ease-in-out infinite; }\n\n /* 居中覆盖生成按钮(无版本 / 非生成中时显示) */\n .mc-triview .overlay-gen-btn {\n position: absolute; top: 50%; left: 50%;\n transform: translate(-50%, -50%);\n z-index: 2;\n display: inline-flex; align-items: center; gap: 6px;\n height: 36px; padding: 0 18px;\n background: var(--heat); color: var(--accent-white);\n border: 0; border-radius: var(--r-pill);\n font-family: inherit; font-size: 13px; font-weight: 500;\n cursor: pointer;\n box-shadow: 0 4px 12px rgba(250, 93, 25, .28);\n transition: filter var(--t-base), opacity var(--t-base), transform var(--t-base), box-shadow var(--t-base);\n }\n .mc-triview .overlay-gen-btn:hover:not(:disabled) {\n filter: brightness(1.06);\n transform: translate(-50%, -50%) scale(1.03);\n box-shadow: 0 6px 16px rgba(250, 93, 25, .36);\n }\n .mc-triview .overlay-gen-btn:disabled {\n background: var(--black-alpha-24); color: var(--surface);\n cursor: not-allowed; box-shadow: none;\n }\n .mc-triview .overlay-gen-btn svg { width: 13px; height: 13px; }\n .mc-triview .overlay-hint {\n position: absolute; left: 0; right: 0; bottom: 10px;\n text-align: center;\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48); letter-spacing: .04em;\n pointer-events: none;\n z-index: 2;\n }\n\n .mc-triview .result-ops { display: flex; gap: 6px; align-items: center; }\n .mc-triview .result-ops .cost { margin-left: auto; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .mc-triview .result-ops button {\n display: inline-flex; align-items: center; gap: 5px;\n height: 28px; padding: 0 10px;\n background: var(--surface); border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n font-family: inherit; font-size: 11.5px;\n color: var(--accent-black); cursor: pointer;\n transition: border-color var(--t-base), color var(--t-base);\n }\n .mc-triview .result-ops button:hover { border-color: var(--heat); color: var(--heat); }\n .mc-triview .result-ops button svg { width: 11px; height: 11px; }\n\n /* 历史版本 strip · 同商品详情页 popover 视觉 */\n .mc-triview .history { display: flex; flex-direction: column; gap: 6px; }\n .mc-triview .history .h-lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }\n .mc-triview .history .h-lbl .ct { color: var(--accent-black); font-weight: 600; }\n .mc-triview .history .h-row { display: flex; gap: 6px; overflow-x: auto; padding: 2px; }\n .mc-triview .history .h-thumb {\n flex: 0 0 auto;\n width: 72px; aspect-ratio: 16/9;\n background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm);\n position: relative; cursor: pointer;\n display: grid; place-items: center; overflow: hidden;\n transition: border-color var(--t-base);\n }\n .mc-triview .history .h-thumb:hover { border-color: var(--heat-40); }\n .mc-triview .history .h-thumb.active { border-color: var(--heat); border-width: 2px; box-shadow: 0 0 0 2px var(--heat-12); }\n .mc-triview .history .h-thumb .v { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-56); letter-spacing: .02em; }\n .mc-triview .history .h-thumb.active .v { color: var(--heat); font-weight: 600; }\n .mc-triview .history .h-thumb .badge { position: absolute; top: 2px; left: 2px; font-family: var(--font-mono); font-size: 8.5px; padding: 0 4px; line-height: 12px; background: var(--heat); color: var(--accent-white); border-radius: 2px; letter-spacing: .02em; display: none; }\n .mc-triview .history .h-thumb.active .badge { display: block; }\n\n /* 底部提交栏 */\n .mc-up-foot { padding: 12px 20px 16px; border-top: 1px solid var(--border-faint); display: flex; align-items: center; gap: 10px; flex-shrink: 0; flex-wrap: wrap; }\n .mc-up-foot .stat { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n .mc-up-foot .stat b { color: var(--accent-black); font-weight: 600; }\n .mc-up-foot .stat.ok { color: var(--heat); }\n .mc-up-foot .commit-btn {\n display: inline-flex; align-items: center; gap: 5px;\n height: 32px; padding: 0 14px;\n background: var(--heat); color: var(--accent-white);\n border: 0; border-radius: var(--r-sm);\n font-family: inherit; font-size: 12.5px; cursor: pointer;\n transition: filter var(--t-base), opacity var(--t-base);\n }\n .mc-up-foot .commit-btn:hover { filter: brightness(1.05); }\n .mc-up-foot .commit-btn:disabled { opacity: .4; cursor: not-allowed; filter: none; }\n .mc-up-foot .commit-btn svg { width: 12px; height: 12px; }\n\n /* 左侧结果卡 · 可点击选为立绘(仅 AI tab) */\n .mc-cell { cursor: pointer; }\n .mc-cell.selected { border-color: var(--heat); box-shadow: 0 0 0 2px var(--heat-12); }\n .mc-cell .pick-badge { position: absolute; top: 6px; left: 6px; background: var(--heat); color: var(--accent-white); padding: 2px 7px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .04em; display: none; }\n .mc-cell.selected .pick-badge { display: block; }\n .ml-toolbar {\n padding: 14px 28px;\n border-bottom: 1px solid var(--border-faint);\n display: flex; align-items: center; gap: 18px;\n flex-shrink: 0;\n flex-wrap: wrap;\n }\n .ml-toolbar .btn-up {\n height: 32px;\n padding: 0 14px;\n display: inline-flex; align-items: center; gap: 6px;\n background: var(--surface);\n border: 1px solid var(--black-alpha-12);\n border-radius: var(--r-sm);\n color: var(--accent-black);\n font-family: inherit;\n font-size: 12.5px;\n cursor: pointer;\n transition: background var(--t-base), border-color var(--t-base);\n }\n .ml-toolbar .btn-up:hover { background: var(--background-lighter); border-color: var(--black-alpha-24); }\n .ml-toolbar .btn-up svg { width: 14px; height: 14px; }\n .ml-toolbar .chip-group {\n display: inline-flex; align-items: center; gap: 6px;\n }\n .ml-toolbar .chip-group .lbl {\n font-family: var(--font-mono);\n font-size: 10.5px;\n color: var(--black-alpha-48);\n letter-spacing: .04em;\n margin-right: 4px;\n }\n .ml-toolbar .chip {\n height: 26px;\n padding: 0 12px;\n border-radius: 999px;\n background: transparent;\n border: 1px solid var(--black-alpha-12);\n color: var(--black-alpha-72);\n font-size: 12px;\n cursor: pointer;\n font-family: inherit;\n transition: background var(--t-base), color var(--t-base), border-color var(--t-base);\n }\n .ml-toolbar .chip:hover { color: var(--accent-black); }\n .ml-toolbar .chip.active {\n background: var(--heat-12); color: var(--heat);\n border-color: var(--heat-40); font-weight: 600;\n }\n .ml-scroll {\n flex: 1; min-height: 0;\n overflow-y: auto;\n padding: 20px 28px 28px;\n }\n .ml-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));\n gap: 12px;\n }\n .ml-grid .model-card { padding: 10px; }\n\n /* 「添加模特」入口卡:虚线轮廓 + 居中 + 号,与现有卡片同尺寸 */\n .ml-grid .ml-upload-card {\n border: 1.5px dashed var(--black-alpha-24);\n background: var(--surface);\n transition: border-color var(--t-base), background var(--t-base), color var(--t-base);\n }\n .ml-grid .ml-upload-card:hover {\n border-color: var(--heat);\n background: var(--heat-12);\n }\n .ml-grid .ml-upload-card:focus-visible {\n outline: 2px solid var(--heat);\n outline-offset: 2px;\n }\n .ml-grid .ml-upload-card .up-thumb {\n aspect-ratio: 3/4;\n border-radius: var(--r-sm);\n background: transparent;\n display: grid; place-items: center;\n }\n .ml-grid .ml-upload-card .up-plus {\n width: 44px; height: 44px;\n border-radius: 50%;\n background: var(--surface);\n border: 1px solid var(--black-alpha-12);\n color: var(--black-alpha-56);\n display: grid; place-items: center;\n transition: background var(--t-base), color var(--t-base), border-color var(--t-base), transform var(--t-base);\n }\n .ml-grid .ml-upload-card:hover .up-plus {\n background: var(--heat); border-color: var(--heat); color: var(--accent-white);\n transform: scale(1.06);\n }\n .ml-grid .ml-upload-card .up-plus svg { width: 22px; height: 22px; }\n .ml-grid .ml-upload-card .m-name { color: var(--accent-black); }\n .ml-grid .ml-upload-card:hover .m-name { color: var(--heat); }\n .ml-grid .ml-upload-card .m-tag { color: var(--black-alpha-48); }\n\n /* ─── 添加模特 · 选择 modal (AI 生成 / 本地上传) ─── */\n .ml-up-choice-bg {\n position: fixed; inset: 0; z-index: 1200;\n background: rgba(21, 20, 15, .42);\n display: none; place-items: center; padding: 16px;\n }\n .ml-up-choice-bg.show { display: grid; }\n .ml-up-choice {\n width: min(560px, 92vw);\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n box-shadow: 0 16px 48px rgba(21, 20, 15, .18);\n overflow: hidden;\n position: relative;\n }\n .ml-up-choice .uc-h {\n display: flex; align-items: center; gap: 12px;\n padding: 18px 22px 14px;\n border-bottom: 1px solid var(--border-faint);\n }\n .ml-up-choice .uc-h .ic-m {\n width: 36px; height: 36px;\n border-radius: var(--r-md);\n background: var(--heat-12); color: var(--heat);\n display: grid; place-items: center;\n flex-shrink: 0;\n }\n .ml-up-choice .uc-h .ic-m svg { width: 18px; height: 18px; }\n .ml-up-choice .uc-h .ti { display: flex; flex-direction: column; gap: 3px; min-width: 0; }\n .ml-up-choice .uc-h .ti strong { font-size: 15px; color: var(--accent-black); font-weight: 600; }\n .ml-up-choice .uc-h .ti .mono { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .ml-up-choice .uc-h .uc-x {\n margin-left: auto;\n width: 28px; height: 28px;\n background: transparent; border: 0; border-radius: var(--r-sm);\n color: var(--black-alpha-56); cursor: pointer;\n display: grid; place-items: center;\n }\n .ml-up-choice .uc-h .uc-x:hover { background: var(--background-lighter); color: var(--accent-black); }\n .ml-up-choice .uc-h .uc-x svg { width: 14px; height: 14px; }\n\n .ml-up-choice .uc-body {\n display: grid; grid-template-columns: 1fr 1fr; gap: 12px;\n padding: 20px 22px 22px;\n }\n .ml-up-choice .uc-option {\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n padding: 18px 16px;\n text-align: left;\n cursor: pointer;\n font-family: inherit;\n display: flex; flex-direction: column; gap: 10px;\n transition: border-color var(--t-base), background var(--t-base), transform var(--t-base);\n }\n .ml-up-choice .uc-option:hover {\n border-color: var(--heat);\n background: var(--heat-12);\n }\n .ml-up-choice .uc-option .opt-ic {\n width: 40px; height: 40px; border-radius: var(--r-md);\n background: var(--background-lighter);\n color: var(--heat); border: 1px solid var(--heat-20);\n display: grid; place-items: center;\n transition: background var(--t-base), color var(--t-base);\n }\n .ml-up-choice .uc-option:hover .opt-ic { background: var(--heat); color: var(--accent-white); border-color: var(--heat); }\n .ml-up-choice .uc-option .opt-ic svg { width: 18px; height: 18px; }\n .ml-up-choice .uc-option .opt-t { font-size: 14px; font-weight: 600; color: var(--accent-black); }\n .ml-up-choice .uc-option .opt-d {\n font-family: var(--font-mono); font-size: 11.5px;\n color: var(--black-alpha-56); letter-spacing: .02em; line-height: 1.55;\n }\n .ml-up-choice .uc-option .opt-tag {\n margin-top: auto;\n align-self: flex-start;\n font-family: var(--font-mono); font-size: 10.5px;\n padding: 2px 8px; border-radius: var(--r-sm);\n background: var(--background-lighter);\n color: var(--black-alpha-72);\n letter-spacing: .04em;\n }\n .ml-up-choice .uc-option:hover .opt-tag { background: var(--surface); color: var(--heat); }\n\n /* 模特详情 居中弹窗 — 参考布局 v2 (与 pipeline.html / library.html 一致) */\n .md-modal-bg { position: fixed; inset: 0; background: rgba(21,20,15,.42); backdrop-filter: blur(8px); z-index: 1000; display: none; align-items: center; justify-content: center; padding: 40px; }\n .md-modal-bg.show { display: flex; }\n .md-modal { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); width: min(1040px, 100%); max-height: calc(100vh - 80px); overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 16px 48px rgba(0,0,0,.18); }\n .md-modal-h { display: flex; align-items: center; gap: 10px; padding: 14px 20px; border-bottom: 1px solid var(--border-faint); }\n .md-modal-h h3 { font-size: 15px; font-weight: 600; }\n .md-modal-h .ad-tag { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .md-modal-h .x { margin-left: auto; width: 30px; height: 30px; display: grid; place-items: center; background: transparent; border: 0; cursor: pointer; color: var(--black-alpha-56); border-radius: var(--r-sm); }\n .md-modal-h .x:hover { background: var(--black-alpha-8); color: var(--accent-black); }\n .md-modal-h .x svg { width: 14px; height: 14px; }\n .md-modal-body { padding: 20px 24px 24px; overflow-y: auto; flex: 1; }\n .md-detail-grid { display: grid; grid-template-columns: 340px 1fr; gap: 24px; }\n /* 左栏 · 大立绘 + 缩略图 */\n .md-lead { display: flex; flex-direction: column; gap: 10px; }\n .md-lead-wrap { position: relative; }\n .md-lead-img { aspect-ratio: 3/4; border-radius: var(--r-md); background: var(--background-lighter); border: 1px solid var(--border-faint); position: relative; overflow: hidden; }\n .md-lead-img .ph-frame { position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); background: rgba(255,255,255,.85); font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-56); padding: 2px 10px; border-radius: var(--r-sm); }\n .md-lead-img .ph-name { position: absolute; left: 0; top: 0; padding: 12px 14px; font-size: 14px; font-weight: 600; color: var(--black-alpha-72); background: linear-gradient(180deg, rgba(255,255,255,.92), rgba(255,255,255,0)); width: 100%; }\n /* 查看大图 icon · 悬浮容器才显示 · 32×32 icon-only */\n .md-zoom-btn { position: absolute; right: 8px; bottom: 8px; width: 32px; height: 32px; padding: 0; background: rgba(21,20,15,.7); color: #fff; border: 0; border-radius: var(--r-sm); display: grid; place-items: center; cursor: pointer; opacity: 0; transition: opacity var(--t-base), background var(--t-base); z-index: 3; }\n .md-zoom-btn:hover { background: rgba(21,20,15,.92); }\n .md-zoom-btn svg { width: 14px; height: 14px; }\n .md-lead-wrap:hover .md-zoom-btn,\n .md-views .md-view:hover .md-zoom-btn { opacity: 1; }\n .md-views .md-view { position: relative; }\n .md-thumbs { display: flex; gap: 8px; }\n .md-thumbs .thumb { flex: 0 0 64px; aspect-ratio: 3/4; border-radius: var(--r-sm); border: 1px solid var(--border-faint); background: var(--background-lighter); cursor: pointer; overflow: hidden; position: relative; }\n .md-thumbs .thumb:hover { border-color: var(--heat-40); }\n .md-thumbs .thumb.active { border-color: var(--heat); border-width: 2px; }\n /* 右栏 sections */\n .md-right .md-section + .md-section { margin-top: 18px; }\n .md-section-h { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; color: var(--accent-black); margin-bottom: 10px; }\n .md-section-h .ic { width: 14px; height: 14px; color: var(--heat); display: grid; place-items: center; }\n .md-section-h .ic svg { width: 14px; height: 14px; }\n .md-section-h .ratio-chip { margin-left: auto; font-family: var(--font-mono); font-size: 10.5px; padding: 2px 8px; border-radius: var(--r-sm); background: var(--background-lighter); border: 1px solid var(--border-faint); color: var(--black-alpha-56); }\n .md-section-h .icon-btn { width: 28px; height: 28px; display: grid; place-items: center; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-56); cursor: pointer; }\n .md-section-h .icon-btn:hover { color: var(--heat); border-color: var(--heat-40); }\n .md-section-h .icon-btn svg { width: 12px; height: 12px; }\n /* 三视图 — 始终单张 16:9 大图 */\n .md-views { display: flex; flex-direction: column; gap: 8px; }\n .md-views .md-view { aspect-ratio: 16/9; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); position: relative; display: grid; place-items: end center; overflow: hidden; }\n .md-views .md-view .lbl { position: absolute; bottom: 8px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); background: rgba(255,255,255,.85); padding: 2px 8px; border-radius: var(--r-sm); }\n /* 三视图 · 用户上传:历史版本横向 strip · 与 mc-triview history 视觉对齐 */\n .md-view-versions { display: flex; gap: 6px; overflow-x: auto; padding: 2px; }\n .md-view-versions .v-thumb { flex: 0 0 auto; width: 72px; aspect-ratio: 16/9; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm); position: relative; cursor: pointer; display: grid; place-items: center; overflow: hidden; transition: border-color var(--t-base); }\n .md-view-versions .v-thumb:hover { border-color: var(--heat-40); }\n .md-view-versions .v-thumb.active { border-color: var(--heat); border-width: 2px; box-shadow: 0 0 0 2px var(--heat-12); }\n .md-view-versions .v-thumb .v { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-56); letter-spacing: .02em; }\n .md-view-versions .v-thumb.active .v { color: var(--heat); font-weight: 600; }\n /* 立绘大图 · 支持 <img> · cover */\n .md-lead-img img.md-lead-pic { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }\n /* 缩略图 · 支持 <img> · 多张时允许换行(避免横向爆款) */\n .md-thumbs { flex-wrap: wrap; }\n .md-thumbs .thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }\n .md-thumbs .thumb .ph-frame { position: absolute; inset: 0; display: grid; place-items: center; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-56); background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px); }\n /* 简介 */\n .md-intro { font-size: 13px; line-height: 1.65; color: var(--black-alpha-72); margin: 0 0 12px; }\n .md-tags { display: flex; flex-wrap: wrap; gap: 8px; }\n .md-tags .tag-chip { height: 26px; padding: 0 12px; display: inline-flex; align-items: center; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm); font-size: 12px; color: var(--accent-black); }\n .md-tags .tag-add { width: 26px; height: 26px; display: grid; place-items: center; background: var(--background-lighter); border: 1px dashed var(--black-alpha-24); border-radius: var(--r-sm); color: var(--black-alpha-56); cursor: pointer; }\n .md-tags .tag-add:hover { border-color: var(--heat); color: var(--heat); }\n .md-tags .tag-add svg { width: 12px; height: 12px; }\n /* 属性表 */\n .md-props { margin-top: 18px; display: grid; grid-template-columns: repeat(3, 1fr); column-gap: 24px; border-top: 1px solid var(--border-faint); padding-top: 16px; }\n .md-props .prop { display: flex; align-items: baseline; padding: 10px 0; border-bottom: 1px solid var(--border-faint); font-size: 12.5px; min-height: 38px; }\n .md-props .prop:nth-last-child(-n+3) { border-bottom: 0; }\n .md-props .prop .k { flex: 0 0 64px; color: var(--black-alpha-56); font-family: var(--font-mono); font-size: 11px; }\n .md-props .prop .v { color: var(--accent-black); font-weight: 500; word-break: break-all; }\n /* footer */\n .md-modal-f { padding: 14px 20px; border-top: 1px solid var(--border-faint); display: flex; align-items: center; gap: 8px; }\n .md-modal-f .foot-stats { display: flex; gap: 6px; margin-right: auto; }\n .md-modal-f .stat-btn { height: 32px; padding: 0 12px; display: inline-flex; align-items: center; gap: 6px; background: transparent; border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-72); font-size: 12.5px; font-family: inherit; cursor: pointer; }\n .md-modal-f .stat-btn:hover { background: var(--background-lighter); color: var(--accent-black); border-color: var(--black-alpha-24); }\n .md-modal-f .stat-btn svg { width: 13px; height: 13px; }\n .md-modal-f .stat-btn b { color: var(--accent-black); font-weight: 600; }\n\n /* ─── 离开工作台 · 二次确认弹窗 ─── */\n .mc-leave-bg {\n position: fixed; inset: 0;\n background: rgba(21,20,15,.42);\n backdrop-filter: blur(8px);\n z-index: 1200;\n display: none;\n align-items: center; justify-content: center;\n padding: 40px;\n }\n .mc-leave-bg.show { display: flex; }\n .mc-leave {\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n width: 420px; max-width: 100%;\n box-shadow: 0 16px 48px rgba(0,0,0,.18);\n overflow: hidden;\n position: relative;\n }\n .mc-leave .lv-h {\n display: flex; align-items: center; gap: 10px;\n padding: 14px 20px 10px;\n }\n .mc-leave .lv-h .ic {\n width: 28px; height: 28px;\n display: grid; place-items: center;\n border-radius: var(--r-sm);\n background: var(--crimson-bg);\n color: var(--accent-crimson);\n flex-shrink: 0;\n }\n .mc-leave .lv-h .ic svg { width: 16px; height: 16px; }\n .mc-leave .lv-h h3 { font-size: 15px; font-weight: 600; color: var(--accent-black); margin: 0; }\n .mc-leave .lv-h .mono {\n margin-left: auto;\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48); letter-spacing: .04em;\n }\n .mc-leave .lv-b {\n padding: 4px 20px 18px;\n font-size: 13px; line-height: 1.65;\n color: var(--black-alpha-72);\n }\n .mc-leave .lv-b b { color: var(--accent-black); font-weight: 600; }\n .mc-leave .lv-f {\n display: flex; align-items: center; gap: 8px;\n padding: 12px 20px;\n border-top: 1px solid var(--border-faint);\n background: var(--background-lighter);\n }\n .mc-leave .lv-f .spacer { flex: 1; }\n .mc-leave .lv-f .btn { height: 34px; padding: 0 14px; font-size: 13px; }\n .mc-leave .btn-danger {\n background: var(--accent-crimson);\n color: var(--accent-white);\n border-color: var(--accent-crimson);\n font-weight: 600;\n }\n .mc-leave .btn-danger:hover {\n background: var(--accent-crimson);\n border-color: var(--accent-crimson);\n filter: brightness(.95);\n }\n\n /* 编辑商品 drawer (复用 .drawer 基础样式, 提高 z-index 覆盖商品库) */\n .pc-drawer { width: 720px; max-width: 100vw; z-index: 1101; }\n .pc-drawer .drawer-b { padding: 24px 28px; }\n #pc-drawer-bg.drawer-bg { z-index: 1100; }\n .pc-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; }\n .pc-field-label { font-size: 13px; font-weight: 500; color: var(--accent-black); }\n .pc-field-label .req { color: var(--accent-crimson); margin-left: 2px; }\n .pc-field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 18px; }\n .pc-field-row > div { display: flex; flex-direction: column; gap: 6px; }\n .pc-bullets {\n list-style: none; padding: 0; margin: 0;\n display: flex; flex-direction: column; gap: 6px;\n }\n .pc-bullets li {\n display: flex; align-items: center; gap: 8px;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n padding: 0 10px;\n height: 36px;\n }\n .pc-bullets li.add { border-style: dashed; border-color: var(--heat-40); }\n .pc-bullets li .num {\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-48); width: 18px; text-align: center;\n flex-shrink: 0;\n }\n .pc-bullets li.add .num { color: var(--heat); }\n .pc-bullets li input {\n flex: 1; border: 0; background: transparent; outline: none;\n font-size: 13px; color: var(--accent-black);\n font-family: inherit;\n }\n .pc-bullets li input::placeholder { color: var(--black-alpha-48); }\n .pc-bullets li .rm {\n width: 22px; height: 22px;\n background: transparent; border: 0; border-radius: var(--r-sm);\n color: var(--black-alpha-48); cursor: pointer;\n display: grid; place-items: center;\n }\n .pc-bullets li .rm:hover { color: var(--accent-crimson); background: var(--black-alpha-4); }\n .pc-bullets li .rm svg { width: 11px; height: 11px; }\n /* 商品图片 grid (对齐 product-detail .ov-images-grid) */\n .pc-imgs {\n display: grid;\n grid-template-columns: repeat(6, 1fr);\n gap: 8px;\n }\n .pc-imgs .thumb {\n aspect-ratio: 1 / 1;\n border-radius: var(--r-sm);\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n position: relative;\n overflow: hidden;\n }\n .pc-imgs .thumb .ph-frame {\n position: absolute; inset: 0;\n display: grid; place-items: center;\n font-family: var(--font-mono); font-size: 10px;\n color: var(--black-alpha-32);\n background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);\n }\n .pc-imgs .thumb .rm {\n position: absolute; top: 4px; right: 4px;\n width: 18px; height: 18px;\n background: rgba(0,0,0,.5);\n color: #fff; border: 0;\n border-radius: var(--r-sm);\n display: grid; place-items: center;\n cursor: pointer;\n opacity: 0;\n transition: opacity var(--t-base);\n }\n .pc-imgs .thumb:hover .rm { opacity: 1; }\n .pc-imgs .thumb .rm svg { width: 10px; height: 10px; }\n .pc-imgs .img-upload {\n aspect-ratio: 1 / 1;\n border-radius: var(--r-sm);\n background: var(--heat-12);\n border: 1.5px dashed var(--heat-40);\n display: grid; place-items: center;\n color: var(--heat);\n cursor: pointer;\n transition: background var(--t-base), border-color var(--t-base);\n }\n .pc-imgs .img-upload:hover { background: var(--heat-20); border-color: var(--heat); }\n .pc-imgs .img-upload svg { width: 18px; height: 18px; }\n</style>\n</head>\n<body>\n<div id=\"page\">\n\n <div class=\"mp-layout\">\n\n <!-- ===== 最左栏 · 商品空间 (单选 · 当前商品决定结果区批次) ===== -->\n <aside class=\"mp-prod-space\" id=\"prod-space\">\n <!-- 顶部 · 返回 + 折叠 (跟图片创作风格一致) -->\n <div class=\"mp-side-top\">\n <button class=\"back-pill\" type=\"button\" onclick=\"history.length > 1 ? history.back() : location.href='asset-factory.html'\" title=\"返回\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M15 18l-6-6 6-6\"/></svg>\n <span>返回</span>\n </button>\n <button class=\"fold\" type=\"button\" title=\"折叠侧栏\" style=\"margin-left:auto\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/><path d=\"M9 3v18\"/></svg>\n </button>\n </div>\n <div class=\"mp-ps-h\">\n <div class=\"mp-ps-search\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/></svg>\n <input type=\"text\" id=\"ps-search-input\" placeholder=\"搜索商品 / 分类\">\n </div>\n </div>\n <!-- 商品列表 标题行 · 右上显眼新建按钮 -->\n <div class=\"mp-list-h\">\n <span class=\"mono\">// 商品空间</span>\n <button class=\"new-prod\" type=\"button\" id=\"ps-new-btn\" title=\"新建商品\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 5v14M5 12h14\"/></svg>\n <span>新建商品</span>\n </button>\n </div>\n <div class=\"mp-ps-list\" id=\"ps-list\"></div>\n <button class=\"mp-ps-all\" type=\"button\" id=\"ps-all-btn\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"2\"/><path d=\"M3 9h18M9 4v16\"/></svg>\n <span>全部商品</span>\n <span class=\"ct\" id=\"ps-all-ct\">0 个</span>\n <svg style=\"margin-left:0\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12h14M12 5l7 7-7 7\"/></svg>\n </button>\n </aside>\n\n <!-- ===== 主区 · 头部 + 参数/结果 双栏 ===== -->\n <section class=\"mp-main\">\n\n <!-- 主区顶部 · toolbar (商品标题 + 搜索 + 筛选 · 跟图片创作一致) -->\n <div class=\"mp-main-h\">\n <button class=\"side-restore-btn\" type=\"button\" id=\"mp-side-restore\" hidden title=\"展开商品空间\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/><path d=\"M9 3v18\"/></svg>\n 商品\n </button>\n <div class=\"cur-title\">\n <span class=\"crumb\">// 商品空间</span>\n <span class=\"nm placeholder\" id=\"cur-prod-nm\">未选择 · 请在左侧商品空间选一个</span>\n </div>\n <span class=\"spacer\"></span>\n <div class=\"tb-search-wrap\" id=\"mp-search-wrap\">\n <button class=\"search-btn\" type=\"button\" title=\"搜索批次/模特\" id=\"mp-search-toggle\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/></svg>\n </button>\n <input type=\"text\" class=\"tb-search-input\" id=\"mp-search-input\" placeholder=\"搜索批次/模特/比例…\" autocomplete=\"off\">\n </div>\n <div class=\"tb-menu-wrap\" data-filter=\"time\">\n <button class=\"tb-chip\" type=\"button\" id=\"mp-chip-time\">\n <span class=\"lbl\">时间</span>\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg>\n </button>\n <div class=\"tb-menu\" id=\"mp-menu-time\" role=\"listbox\" aria-labelledby=\"mp-chip-time\">\n <button class=\"tb-menu-item active\" type=\"button\" data-val=\"all\">全部时间</button>\n <button class=\"tb-menu-item\" type=\"button\" data-val=\"today\">今天</button>\n <button class=\"tb-menu-item\" type=\"button\" data-val=\"1h\">1 小时内</button>\n <button class=\"tb-menu-item\" type=\"button\" data-val=\"10min\">10 分钟内</button>\n </div>\n </div>\n <div class=\"tb-menu-wrap\" data-filter=\"model\">\n <button class=\"tb-chip\" type=\"button\" id=\"mp-chip-model\">\n <span class=\"lbl\">模特</span>\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg>\n </button>\n <div class=\"tb-menu\" id=\"mp-menu-model\" role=\"listbox\" aria-labelledby=\"mp-chip-model\">\n <button class=\"tb-menu-item active\" type=\"button\" data-val=\"all\">全部模特</button>\n <div class=\"tb-menu-empty\">暂无批次,生成后可按模特筛选</div>\n </div>\n </div>\n </div>\n\n <div class=\"mp-main-body\">\n\n <!-- 左 · 参数 -->\n <div class=\"mp-form\">\n\n <!-- ① 选择模特 -->\n <div class=\"mp-step\">\n <div class=\"mp-step-h\">\n <span class=\"num\">1</span>\n <span class=\"title\">选择模特</span>\n <span class=\"right\" id=\"open-model-lib\">全部模特 →</span>\n </div>\n <div class=\"model-grid\" id=\"model-grid-mini\">\n <div class=\"model-card\" data-id=\"m1\" data-name=\"Ava\">\n <div class=\"m-check\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></div>\n <div class=\"placeholder m-thumb\"><span class=\"ph-frame\">Ava</span></div>\n <div class=\"m-name\">Ava</div>\n <div class=\"m-tag\">亚洲·25岁·清新</div>\n </div>\n <div class=\"model-card\" data-id=\"m2\" data-name=\"Luna\">\n <div class=\"m-check\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></div>\n <div class=\"placeholder m-thumb\"><span class=\"ph-frame\">Luna</span></div>\n <div class=\"m-name\">Luna</div>\n <div class=\"m-tag\">亚洲·22岁·学生</div>\n </div>\n <div class=\"model-card\" data-id=\"m3\" data-name=\"Mia\">\n <div class=\"m-check\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></div>\n <div class=\"placeholder m-thumb\"><span class=\"ph-frame\">Mia</span></div>\n <div class=\"m-name\">Mia</div>\n <div class=\"m-tag\">混血·28岁·OL</div>\n </div>\n <div class=\"model-card\" data-id=\"m4\" data-name=\"Zoe\">\n <div class=\"m-check\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></div>\n <div class=\"placeholder m-thumb\"><span class=\"ph-frame\">Zoe</span></div>\n <div class=\"m-name\">Zoe</div>\n <div class=\"m-tag\">亚洲·30岁·健身</div>\n </div>\n </div>\n </div>\n\n <!-- ② 生成设置 -->\n <div class=\"mp-step\">\n <div class=\"mp-step-h\">\n <span class=\"num\">2</span>\n <span class=\"title\">生成设置</span>\n </div>\n <div class=\"mp-sub\">\n <div class=\"mp-sub-h\">// 生成数量 (每模特)</div>\n <div class=\"pill-row\" data-key=\"count\">\n <button type=\"button\" class=\"opt active\" data-val=\"4\">4 张</button>\n <button type=\"button\" class=\"opt\" data-val=\"8\">8 张</button>\n <button type=\"button\" class=\"opt\" data-val=\"12\">12 张</button>\n </div>\n </div>\n <div class=\"mp-sub\">\n <div class=\"mp-sub-h\">// 图片比例</div>\n <div class=\"pill-row\" data-key=\"ratio\">\n <button type=\"button\" class=\"opt active\" data-val=\"1:1\">1:1</button>\n <button type=\"button\" class=\"opt\" data-val=\"3:4\">3:4</button>\n <button type=\"button\" class=\"opt\" data-val=\"9:16\">9:16</button>\n </div>\n </div>\n </div>\n\n <!-- 底部 立即生成 -->\n <div class=\"mp-cta\">\n <button class=\"btn btn-primary btn-gen\" id=\"mp-go-btn\" type=\"button\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 3l14 9-14 9V3z\"/></svg>\n 立即生成 (预估 <span id=\"cost-total\">¥1.20</span>)\n </button>\n <div class=\"mp-cta-hint\">// 采用即扣费并入对应商品 AI 素材 · 未采用不扣</div>\n </div>\n\n </div>\n\n <!-- ===== 右栏 · 预览 ===== -->\n <div class=\"mp-preview\">\n <!-- 空态(新任务态 & 还没立即生成时显示) -->\n <div class=\"mp-pv-empty\" id=\"pv-empty\">\n <div class=\"mono\">// EMPTY STATE</div>\n <div class=\"title\">还没有生成结果</div>\n <div class=\"hint\">先选商品、选模特,点击 <b>立即生成</b> 后,效果图会出现在这里</div>\n </div>\n\n <!-- pv-summary 已并入主区头部 toolbar,仅保留隐藏 DOM 让旧 JS 引用不报错 -->\n <div class=\"mp-pv-h\" id=\"pv-summary\" hidden style=\"display:none\">\n <div class=\"pv-meta\"><b id=\"pv-count\">4</b> 张 · <b id=\"pv-ratio\">1:1</b></div>\n <div class=\"pv-line\"><span class=\"v\" id=\"pv-prod\">未选择</span></div>\n <div class=\"pv-line\"><span class=\"v\" id=\"pv-model\">未选择</span><span class=\"swap\" id=\"pv-swap\">更换</span></div>\n </div>\n\n <div class=\"mp-pv-grid\" id=\"pv-grid\">\n <!-- 默认占位批次; 生成后填充为真实批次 -->\n <div class=\"mp-result-batch placeholder-batch\">\n <div class=\"mp-result-grid\">\n <div class=\"mp-result placeholder-only\"><div class=\"mp-r-thumb\"><span class=\"ph-frame\">待生成 · 1:1</span></div></div>\n <div class=\"mp-result placeholder-only\"><div class=\"mp-r-thumb\"><span class=\"ph-frame\">待生成 · 1:1</span></div></div>\n <div class=\"mp-result placeholder-only\"><div class=\"mp-r-thumb\"><span class=\"ph-frame\">待生成 · 1:1</span></div></div>\n <div class=\"mp-result placeholder-only\"><div class=\"mp-r-thumb\"><span class=\"ph-frame\">待生成 · 1:1</span></div></div>\n </div>\n </div>\n </div>\n\n <div class=\"mp-pv-foot\" id=\"pv-foot\">\n // 采用即扣费并入对应商品的 <a href=\"products.html\">AI 素材库 →</a>;未采用的图不扣费、不保存\n <br>// 切换左侧商品空间 · 查看其他商品的批次记录\n </div>\n </div>\n\n </div><!-- /.mp-main-body -->\n </section><!-- /.mp-main -->\n\n </div>\n</div>\n\n<!-- ===== 商品库 全屏(无遮罩自适应,多选) ===== -->\n<div class=\"pl-modal-bg\" id=\"pl-modal-bg\">\n <div class=\"pl-modal\">\n <div class=\"pl-modal-h\">\n <h2>商品库</h2>\n <span class=\"ct\" id=\"pl-total-ct\">// 共 7 个商品</span>\n <div class=\"actions\">\n <button class=\"x\" type=\"button\" id=\"pl-close-btn\" aria-label=\"关闭\" style=\"width:32px;height:32px;display:grid;place-items:center;background:transparent;border:0;border-radius:var(--r-sm);cursor:pointer;color:var(--black-alpha-56)\">\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6 6l12 12M6 18L18 6\"/></svg>\n </button>\n </div>\n </div>\n <div class=\"pl-modal-body\">\n <aside class=\"pl-side\">\n <div class=\"pl-side-h\">分类</div>\n <div class=\"pl-side-item active\" data-cat=\"\">全部 <span class=\"ct\" id=\"pl-ct-all\">7</span></div>\n <div class=\"pl-side-item\" data-cat=\"美妆个护\">美妆个护 <span class=\"ct\">2</span></div>\n <div class=\"pl-side-item\" data-cat=\"数码 3C\">数码 3C <span class=\"ct\">1</span></div>\n <div class=\"pl-side-item\" data-cat=\"食品饮料\">食品饮料 <span class=\"ct\">2</span></div>\n <div class=\"pl-side-item\" data-cat=\"家居家电\">家居家电 <span class=\"ct\">1</span></div>\n <div class=\"pl-side-item\" data-cat=\"运动户外\">运动户外 <span class=\"ct\">1</span></div>\n </aside>\n <div class=\"pl-main\">\n <div class=\"pl-toolbar\">\n <div class=\"search\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/></svg>\n <input type=\"text\" id=\"pl-search-input\" placeholder=\"搜索商品名\">\n </div>\n <button class=\"btn-new\" type=\"button\" id=\"pl-new-btn\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 5v14M5 12h14\"/></svg>\n 新建商品\n </button>\n </div>\n <div class=\"pl-scroll\">\n <div class=\"pl-grid\" id=\"pl-grid\">\n <!-- JS 渲染 -->\n </div>\n </div>\n </div>\n </div>\n <div class=\"pl-modal-f\">\n <div class=\"summary\">// 已选 <b id=\"pl-sel-ct\">0</b> 个商品</div>\n <button class=\"btn\" type=\"button\" id=\"pl-cancel-btn\">取消</button>\n <button class=\"btn btn-primary\" type=\"button\" id=\"pl-confirm-btn\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M5 12l5 5L20 7\"/></svg>\n 确认选择\n </button>\n </div>\n </div>\n</div>\n\n<!-- ===== 编辑商品 drawer (在商品库内点编辑触发,prefilled) ===== -->\n<div class=\"drawer-bg\" id=\"pc-drawer-bg\"></div>\n<aside class=\"drawer pc-drawer\" id=\"pc-drawer\" role=\"dialog\" aria-label=\"编辑商品\" aria-hidden=\"true\">\n <div class=\"drawer-h\">\n <h3 id=\"pc-drawer-title\">编辑商品</h3>\n <button class=\"x\" type=\"button\" id=\"pc-drawer-close\" aria-label=\"关闭\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6 6l12 12M6 18L18 6\"/></svg>\n </button>\n </div>\n <div class=\"drawer-b\">\n <div class=\"pc-field\">\n <label class=\"pc-field-label\">商品名称<span class=\"req\">*</span></label>\n <input class=\"input\" id=\"pcf-name\" placeholder=\"请输入商品名称(必填)\" maxlength=\"100\">\n </div>\n <div class=\"pc-field-row\">\n <div>\n <label class=\"pc-field-label\">品类<span class=\"req\">*</span></label>\n <select class=\"select\" id=\"pcf-cat\">\n <option>美妆个护</option>\n <option>服饰内衣</option>\n <option>食品饮料</option>\n <option>家居家电</option>\n <option>数码 3C</option>\n <option>个护清洁</option>\n <option>运动户外</option>\n <option>母婴亲子</option>\n </select>\n </div>\n <div>\n <label class=\"pc-field-label\">目标人群<span style=\"color:var(--black-alpha-48);margin-left:2px\">(选填)</span></label>\n <input class=\"input\" id=\"pcf-target\" placeholder=\"例: 22-32 岁女性、敏感肌、办公室通勤\">\n </div>\n </div>\n <div class=\"pc-field\">\n <label class=\"pc-field-label\">商品图片<span style=\"color:var(--black-alpha-48);margin-left:2px\">(<span id=\"pcf-imgs-ct\">6</span>)</span></label>\n <div class=\"pc-imgs\" id=\"pcf-imgs\"></div>\n </div>\n <div class=\"pc-field\">\n <label class=\"pc-field-label\">核心卖点<span class=\"req\">*</span></label>\n <ul class=\"pc-bullets\" id=\"pcf-bullets\">\n <li class=\"add\"><span class=\"num\">+</span><input id=\"pcf-add-input\" placeholder=\"添加新卖点 · 回车确认\"></li>\n </ul>\n </div>\n </div>\n <div class=\"drawer-f\">\n <button class=\"btn\" type=\"button\" id=\"pc-cancel-btn\">取消</button>\n <button class=\"btn btn-primary\" type=\"button\" id=\"pc-save-btn\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 12l5 5L20 6\"/></svg>\n 保存修改\n </button>\n </div>\n</aside>\n\n<!-- ===== 模特库 全屏(无遮罩自适应) ===== -->\n<div class=\"ml-modal-bg\" id=\"ml-modal-bg\">\n <div class=\"ml-modal\">\n <div class=\"ml-modal-h\">\n <h2>模特库</h2>\n <button class=\"x\" type=\"button\" id=\"ml-close-btn\" aria-label=\"关闭\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6 6l12 12M6 18L18 6\"/></svg>\n </button>\n </div>\n <div class=\"ml-modal-body\">\n <aside class=\"ml-side\">\n <div class=\"ml-side-h\">来源</div>\n <div class=\"ml-side-item active\" data-source=\"all\">全部 <span class=\"ct\">12</span></div>\n <div class=\"ml-side-item\" data-source=\"preset\">平台预设 <span class=\"ct\">10</span></div>\n <div class=\"ml-side-item\" data-source=\"own\">我的上传 <span class=\"ct\">2</span></div>\n </aside>\n <div class=\"ml-main\">\n <div class=\"ml-toolbar\">\n <div class=\"chip-group\" data-key=\"gender\">\n <span class=\"lbl\">性别</span>\n <button class=\"chip active\" type=\"button\" data-val=\"\">全部</button>\n <button class=\"chip\" type=\"button\" data-val=\"女\">女</button>\n <button class=\"chip\" type=\"button\" data-val=\"男\">男</button>\n </div>\n <div class=\"chip-group\" data-key=\"age\">\n <span class=\"lbl\">年龄</span>\n <button class=\"chip active\" type=\"button\" data-val=\"\">全部</button>\n <button class=\"chip\" type=\"button\" data-val=\"青年\">青年</button>\n <button class=\"chip\" type=\"button\" data-val=\"中年\">中年</button>\n </div>\n </div>\n <div class=\"ml-scroll\">\n <div class=\"ml-grid\" id=\"ml-grid\">\n <!-- 12 个模特卡片 (placeholder) -->\n </div>\n </div>\n\n <!-- 添加模特 · 工作台画布(默认隐藏,点「添加模特」卡片后展开) -->\n <div class=\"ml-canvas\" id=\"ml-canvas\" aria-hidden=\"true\">\n <div class=\"ml-canvas-h\">\n <button class=\"back-btn\" type=\"button\" id=\"ml-canvas-back\" aria-label=\"返回模特库\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19 12H5M12 19l-7-7 7-7\"/></svg>\n 返回\n </button>\n <h3>添加模特</h3>\n <span class=\"mono\">// 添加模特 · 工作台</span>\n <span style=\"flex:1;\"></span>\n </div>\n <div class=\"ml-canvas-body\">\n\n <!-- 左 · AI 生成(主视觉,照搬图片创作页面的内容区) -->\n <section class=\"mc-ai\">\n <div class=\"mc-stream\" id=\"mc-stream\">\n <div class=\"mc-stream-inner\" id=\"mc-stream-inner\">\n <div class=\"mc-empty\">\n <div class=\"ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 2l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 2z\"/><path d=\"M19 16l.85 2.3L22 19l-2.15.7L19 22l-.85-2.3L16 19l2.15-.7L19 16z\"/></svg>\n </div>\n <span class=\"badge\">// AI · STUDIO</span>\n <h2>用 AI 生成一位新模特</h2>\n <p>描述外形 + 风格 + 服装,AI 会同时生成立绘 + 正/侧/背三视图,加入模特库。</p>\n <div class=\"examples\">\n <button class=\"ex\" type=\"button\" data-ex=\"清新校园风女生,黑色长直发,白色 T 恤 + 牛仔短裙,室内自然光\">清新校园风女生</button>\n <button class=\"ex\" type=\"button\" data-ex=\"都市 OL 通勤,黑色西装套装,30 岁知性气质\">都市 OL 通勤</button>\n <button class=\"ex\" type=\"button\" data-ex=\"健身房教练男性,运动背心 + 短裤,健身房布景\">健身教练 · 男</button>\n <button class=\"ex\" type=\"button\" data-ex=\"日系简约女生,棕色短发,米色针织衫,温柔气质\">日系简约</button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"mc-input-wrap\">\n <div class=\"mc-input\">\n <!-- 上行 · 参考图 + 加号 (同一 flex 行,均 64×64) -->\n <div class=\"mc-input-top\">\n <div class=\"mc-input-refs\" id=\"mc-input-refs\"></div>\n <button class=\"add-btn\" type=\"button\" id=\"mc-add-btn\" title=\"上传参考图\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 5v14M5 12h14\"/></svg>\n </button>\n <input type=\"file\" id=\"mc-ai-ref-input\" accept=\"image/*\" multiple hidden>\n </div>\n\n <!-- 中行 · textarea 满宽 -->\n <textarea id=\"mc-input-text\" rows=\"1\" placeholder=\"描述模特外形、年龄、风格、服饰…例如:清新校园风女生,黑色长直发\"></textarea>\n\n <!-- 下行 · 参数 + 右 meta + 发送按钮 -->\n <div class=\"mc-input-bottom\">\n <div class=\"param\"><span class=\"lbl-mono\">比例</span><span>3:4</span><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></div>\n <div class=\"param\"><span class=\"lbl-mono\">风格</span><span>默认</span><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></div>\n <div class=\"param\"><span class=\"lbl-mono\">张数</span><span>4</span><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></div>\n <span class=\"right-meta\">预估 <span class=\"val\">¥0.80</span> · 余额 <span class=\"val\">¥327.40</span></span>\n <button class=\"send-btn\" type=\"button\" id=\"mc-send-btn\" disabled title=\"生成\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12h14M12 5l7 7-7 7\"/></svg>\n </button>\n </div>\n </div>\n </div>\n </section>\n\n <!-- 右 · 副视觉 · tab 切换(AI 生成 / 本地上传)+ 3 模块(姓名/立绘/三视图) -->\n <aside class=\"mc-up\">\n <div class=\"mc-up-tabs\">\n <button class=\"mc-up-tab active\" type=\"button\" data-tab=\"ai\">AI 生成</button>\n <button class=\"mc-up-tab\" type=\"button\" data-tab=\"local\">本地上传</button>\n </div>\n <div class=\"mc-up-body\">\n\n <!-- ① 模特姓名 -->\n <div class=\"mc-up-section\">\n <div class=\"mc-up-sec-h\">// 模特姓名</div>\n <input class=\"mc-up-name\" type=\"text\" id=\"mc-up-name\" placeholder=\"给模特起个名字…\" maxlength=\"20\">\n </div>\n\n <!-- ② 模特立绘 -->\n <div class=\"mc-up-section\">\n <div class=\"mc-up-sec-h\">// 模特立绘</div>\n <!-- AI 模式:从左侧 AI 生成结果选中 -->\n <div class=\"mc-portrait-ai\" data-show=\"ai\">\n <div class=\"empty\" id=\"mc-portrait-ai-empty\">\n <div class=\"ic\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"9\" cy=\"8\" r=\"4\"/><path d=\"M3 21c0-3.5 3-6 6-6s6 2.5 6 6\"/></svg></div>\n <div class=\"desc\">在左侧 AI 生成后<br>点击想要的立绘添加到这里</div>\n <div class=\"mono\">// 待选中</div>\n </div>\n <div class=\"picked\" id=\"mc-portrait-ai-picked\" hidden>\n <span class=\"badge\">已选用</span>\n <div class=\"ph-frame\" id=\"mc-portrait-ai-label\">模特立绘</div>\n <div class=\"ops\">\n <button type=\"button\" id=\"mc-portrait-ai-clear\" title=\"移除\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M18 6L6 18M6 6l12 12\"/></svg>\n </button>\n </div>\n </div>\n </div>\n <!-- 本地模式:上传多张 -->\n <div class=\"mc-portrait-local\" data-show=\"local\" hidden>\n <div class=\"drop\" id=\"mc-portrait-local-drop\" tabindex=\"0\" role=\"button\" aria-label=\"点击或拖入立绘\">\n <div class=\"ic\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4\"/><polyline points=\"17 8 12 3 7 8\"/><line x1=\"12\" y1=\"3\" x2=\"12\" y2=\"15\"/></svg></div>\n <div class=\"t\">点击或拖入立绘</div>\n <div class=\"d\">支持多张 JPG / PNG / WEBP · ≤ 10MB / 张</div>\n </div>\n <div class=\"list-h\">\n <span>// 已上传</span>\n <span class=\"ct\" id=\"mc-portrait-local-count\">0</span>\n <span>张</span>\n </div>\n <div class=\"list\" id=\"mc-portrait-local-list\"></div>\n <input type=\"file\" id=\"mc-portrait-local-input\" accept=\"image/*\" multiple hidden>\n </div>\n </div>\n\n <!-- ③ 模特三视图 · 16:9 占位始终在,空态时居中覆盖生成按钮 -->\n <div class=\"mc-up-section mc-triview\" id=\"mc-triview-sec\">\n <div class=\"mc-up-sec-h\">// 模特三视图</div>\n <div class=\"result-wrap\">\n <div class=\"result\" id=\"mc-triview-result\">\n <div class=\"ph-frame\" id=\"mc-triview-frame\">三视图(正/侧/背)</div>\n <!-- 空态居中按钮 + 提示 -->\n <button class=\"overlay-gen-btn\" type=\"button\" id=\"mc-triview-gen-btn\" disabled>\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8L12 3z\"/></svg>\n 生成三视图\n </button>\n <div class=\"overlay-hint\" id=\"mc-triview-hint\">// 先选中左侧 AI 立绘</div>\n </div>\n <!-- 有版本时的操作行 + 历史 -->\n <div class=\"result-ops\" id=\"mc-triview-ops\" hidden>\n <button type=\"button\" id=\"mc-triview-rerun\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 12a9 9 0 1 0 3-6.7\"/><path d=\"M3 4v5h5\"/></svg> 重跑</button>\n <span class=\"cost\">~¥0.30 / 次</span>\n </div>\n <div class=\"history\" id=\"mc-triview-history\" hidden>\n <div class=\"h-lbl\">// 历史版本 · <span class=\"ct\" id=\"mc-triview-history-count\">0</span> 版</div>\n <div class=\"h-row\" id=\"mc-triview-history-row\"></div>\n </div>\n </div>\n </div>\n\n </div>\n <div class=\"mc-up-foot\">\n <span class=\"stat\" id=\"mc-up-stat\">// 待完成</span>\n <button type=\"button\" class=\"commit-btn\" id=\"mc-up-commit\" disabled>\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 12l5 5L20 6\"/></svg>\n 加入模特库\n </button>\n </div>\n </aside>\n\n </div>\n </div>\n </div>\n </div>\n </div>\n</div>\n\n<!-- ===== 添加模特 · 选择来源 ===== -->\n<div class=\"ml-up-choice-bg\" id=\"ml-up-choice-bg\">\n <div class=\"ml-up-choice\" role=\"dialog\" aria-label=\"添加模特\">\n <div class=\"uc-h\">\n <div class=\"ic-m\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"9\" cy=\"8\" r=\"4\"/><path d=\"M3 21c0-3.5 3-6 6-6s6 2.5 6 6\"/><path d=\"M19 8v6M22 11h-6\"/></svg>\n </div>\n <div class=\"ti\">\n <strong>添加模特</strong>\n <span class=\"mono\">// 选择来源 · AI 生成或本地上传</span>\n </div>\n <button class=\"uc-x\" type=\"button\" id=\"ml-up-x\" aria-label=\"关闭\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\"><path d=\"M18 6L6 18M6 6l12 12\"/></svg>\n </button>\n </div>\n <div class=\"uc-body\">\n <button type=\"button\" class=\"uc-option\" id=\"ml-up-ai\">\n <span class=\"opt-ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 2l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 2z\"/><path d=\"M19 16l.85 2.3L22 19l-2.15.7L19 22l-.85-2.3L16 19l2.15-.7L19 16z\"/></svg>\n </span>\n <div class=\"opt-t\">AI 生成</div>\n <div class=\"opt-d\">描述外形 + 风格,AI 自动生成新模特形象与三视图</div>\n <span class=\"opt-tag\">[ AI · STUDIO ]</span>\n </button>\n <button type=\"button\" class=\"uc-option\" id=\"ml-up-local\">\n <span class=\"opt-ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4\"/><polyline points=\"17 8 12 3 7 8\"/><line x1=\"12\" y1=\"3\" x2=\"12\" y2=\"15\"/></svg>\n </span>\n <div class=\"opt-t\">本地上传</div>\n <div class=\"opt-d\">上传商家真人 / 既有素材,后续可生成三视图统一镜头</div>\n <span class=\"opt-tag\">[ UPLOAD ]</span>\n </button>\n </div>\n </div>\n</div>\n<input type=\"file\" id=\"ml-up-file\" accept=\"image/*\" multiple hidden>\n\n<!-- ===== 模特详情 居中弹窗 (参考布局 v2) ===== -->\n<div class=\"md-modal-bg\" id=\"md-modal-bg\">\n <div class=\"md-modal\">\n <div class=\"md-modal-h\">\n <h3 id=\"md-title\">模特详情</h3>\n <span class=\"ad-tag\" id=\"md-kind\">/ 人物 · 模特</span>\n <button class=\"x\" type=\"button\" id=\"md-close-btn\" aria-label=\"关闭\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6 6l12 12M6 18L18 6\"/></svg>\n </button>\n </div>\n <div class=\"md-modal-body\">\n <div class=\"md-detail-grid\">\n <!-- 左栏 · 大立绘 + 缩略图 -->\n <div class=\"md-lead\">\n <div class=\"md-lead-wrap\">\n <div class=\"md-lead-img\">\n <div class=\"ph-name\" id=\"md-portrait-name\">—</div>\n <div class=\"ph-frame\">立绘 · 3:4</div>\n </div>\n <button class=\"md-zoom-btn\" type=\"button\" id=\"md-lead-zoom\" aria-label=\"查看大图\" title=\"查看大图\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5\"/></svg>\n </button>\n </div>\n <div class=\"md-thumbs\" id=\"md-thumbs\"></div>\n </div>\n <!-- 右栏 · 三视图 + 简介 + 属性 -->\n <div class=\"md-right\">\n <div class=\"md-section\">\n <div class=\"md-section-h\">\n <span class=\"ic\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"7\" height=\"7\"/><rect x=\"14\" y=\"3\" width=\"7\" height=\"7\"/><rect x=\"3\" y=\"14\" width=\"7\" height=\"7\"/></svg></span>\n <span class=\"t\">三视图</span>\n <span class=\"ratio-chip\">16:9</span>\n <button class=\"icon-btn\" type=\"button\" title=\"下载\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3\"/></svg></button>\n </div>\n <div class=\"md-views\">\n <div class=\"md-view\"><div class=\"lbl\">正 / 侧 / 背 · 三视图</div></div>\n </div>\n </div>\n <div class=\"md-section\">\n <div class=\"md-section-h\">\n <span class=\"ic\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 6h16M4 12h16M4 18h10\"/></svg></span>\n <span class=\"t\">简介</span>\n </div>\n <p class=\"md-intro\" id=\"md-intro\">—</p>\n <div class=\"md-tags\" id=\"md-tags\"></div>\n </div>\n <div class=\"md-props\" id=\"md-props\"></div>\n </div>\n </div>\n </div>\n <div class=\"md-modal-f\">\n <div class=\"foot-stats\">\n <button class=\"stat-btn\" type=\"button\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3\"/></svg>\n 下载\n </button>\n </div>\n <button class=\"btn btn-primary\" type=\"button\" id=\"md-select\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M5 12l5 5L20 7\"/></svg>\n 选用此模特\n </button>\n </div>\n </div>\n</div>\n\n<!-- ===== 缺三视图 · 保存前提醒弹窗 ===== -->\n<div class=\"mc-leave-bg\" id=\"mc-notri-bg\" aria-hidden=\"true\">\n <div class=\"mc-leave\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"mc-notri-title\">\n <div class=\"lv-h\">\n <span class=\"ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 9v4M12 17h.01M10.3 3.86l-8.18 14.18A2 2 0 0 0 3.84 21h16.32a2 2 0 0 0 1.72-2.96L13.7 3.86a2 2 0 0 0-3.4 0z\"/></svg>\n </span>\n <h3 id=\"mc-notri-title\">缺三视图 · 仍要保存吗?</h3>\n <span class=\"mono\">// MISSING TRI-VIEW</span>\n </div>\n <div class=\"lv-b\">\n 该模特尚未生成 <b>正 / 侧 / 背</b> 三视图。直接进入后续图片/视频生成时,模型缺少多角度参考,<b>角色一致性、姿态稳定性可能下降</b>。\n <br><br>\n 建议先点「<b>去生成三视图</b>」补齐(约 12s · ¥0.30);若现在不生成,后续也可以在<b>资产详情页</b>里随时补回。\n </div>\n <div class=\"lv-f\">\n <span class=\"spacer\"></span>\n <button class=\"btn\" type=\"button\" id=\"mc-notri-save\">仍要保存</button>\n <button class=\"btn btn-primary\" type=\"button\" id=\"mc-notri-gen\">去生成三视图</button>\n </div>\n </div>\n</div>\n\n<!-- ===== 离开工作台 · 二次确认弹窗 ===== -->\n<div class=\"mc-leave-bg\" id=\"mc-leave-bg\" aria-hidden=\"true\">\n <div class=\"mc-leave\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"mc-leave-title\">\n <div class=\"lv-h\">\n <span class=\"ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 9v4M12 17h.01M10.3 3.86l-8.18 14.18A2 2 0 0 0 3.84 21h16.32a2 2 0 0 0 1.72-2.96L13.7 3.86a2 2 0 0 0-3.4 0z\"/></svg>\n </span>\n <h3 id=\"mc-leave-title\">退出工作台?</h3>\n <span class=\"mono\">// UNSAVED</span>\n </div>\n <div class=\"lv-b\" id=\"mc-leave-body\">\n 工作台已有内容,退出后<b>不会保存</b>。可继续编辑并点「加入模特库」来保留进度。\n </div>\n <div class=\"lv-f\">\n <span class=\"spacer\"></span>\n <button class=\"btn\" type=\"button\" id=\"mc-leave-cancel\">继续编辑</button>\n <button class=\"btn btn-danger\" type=\"button\" id=\"mc-leave-confirm\">不保存,退出</button>\n </div>\n </div>\n</div>\n\n<script src=\"/exact/assets/icons.js?v=2026052608\"></script>\n<script src=\"/exact/assets/shell.js?v=2026060101\"></script>\n<script src=\"/exact/assets/new-product-drawer.js?v=202605211643\"></script>\n<script>\nShell.render({\n active: 'asset-factory',\n crumbs: [\n { label: '工作台', href: 'index.html' },\n { label: '图片生成', href: 'asset-factory.html' },\n { label: '模特上身图' }\n ]\n});\n\n// ─── 商品库数据 (mock,与 products.html 7 个商品对齐) ───\nconst PRODUCTS = [\n { id: 'p1', name: '透真玻尿酸补水面膜', cat: '美妆个护', meta: '熬夜党 · 124 素材' },\n { id: 'p2', name: '南卡 Lite Pro 蓝牙耳机', cat: '数码 3C', meta: '通勤 · 96 素材' },\n { id: 'p3', name: '滋啦速食牛肉面 6 桶装', cat: '食品饮料', meta: '加班 · 96 素材' },\n { id: 'p4', name: '透真清透物理防晒霜', cat: '美妆个护', meta: 'SPF50 · 76 素材' },\n { id: 'p5', name: '三顿半同款冻干咖啡粉', cat: '食品饮料', meta: '提神 · 68 素材' },\n { id: 'p6', name: '小熊 4L 可视空气炸锅', cat: '家居家电', meta: '小户型 · 54 素材' },\n { id: 'p7', name: '露露同款裸感瑜伽裤', cat: '运动户外', meta: '健身房 · 42 素材' },\n];\n\n// ─── State (单选 · 默认全空) ───\nconst state = {\n selectedProd: null, // string | null\n selectedModel: null, // string | null\n count: 4,\n ratio: '1:1',\n};\nconst UNIT_PRICE = 0.30;\n\n// ─── 商品空间 (左侧栏) 渲染 ───\nlet _psQuery = '';\nfunction renderProdSpace() {\n const listEl = document.getElementById('ps-list');\n const ctEl = document.getElementById('ps-count');\n const allCtEl = document.getElementById('ps-all-ct');\n if (!listEl) return;\n if (ctEl) ctEl.textContent = PRODUCTS.length;\n if (allCtEl) allCtEl.textContent = PRODUCTS.length + ' 个';\n const q = _psQuery.trim();\n const filtered = q\n ? PRODUCTS.filter(p => p.name.includes(q) || p.cat.includes(q))\n : PRODUCTS;\n if (!filtered.length) {\n listEl.innerHTML = `<div class=\"mp-ps-empty\">// NO MATCH<br>试试其他关键词</div>`;\n return;\n }\n listEl.innerHTML = filtered.map(p => `\n <div class=\"mp-prod-item${state.selectedProd === p.id ? ' active' : ''}\" data-id=\"${p.id}\">\n <div class=\"placeholder thumb\"></div>\n <div class=\"body\">\n <div class=\"nm\">${p.name}</div>\n <div class=\"sub\">// ${p.cat}</div>\n </div>\n </div>\n `).join('');\n listEl.querySelectorAll('.mp-prod-item').forEach(el => {\n el.addEventListener('click', () => selectProduct(el.dataset.id));\n });\n}\n\n// 选中商品 (sidebar 单选 · 同步更新表单/预览/Cost)\nfunction selectProduct(id) {\n state.selectedProd = id;\n // 商品空间 active 态\n document.querySelectorAll('.mp-prod-item').forEach(el => {\n el.classList.toggle('active', el.dataset.id === id);\n });\n // 当前商品 header strip\n updateCurProdHeader();\n // 预览区: 按商品过滤批次重渲染\n if (typeof renderBatchesForCurrentProd === 'function') renderBatchesForCurrentProd();\n // 同步 pv-summary 商品名\n const p = PRODUCTS.find(x => x.id === id);\n document.getElementById('pv-prod').textContent = p ? p.name : '未选择';\n updateCost();\n}\n\n// 当前商品 header strip\nfunction updateCurProdHeader() {\n const nmEl = document.getElementById('cur-prod-nm');\n const statsEl = document.getElementById('cur-prod-stats');\n const batchesEl = document.getElementById('cur-prod-batches');\n if (!nmEl) return;\n const p = state.selectedProd ? PRODUCTS.find(x => x.id === state.selectedProd) : null;\n if (!p) {\n nmEl.textContent = '未选择 · 请在左侧商品空间选一个';\n nmEl.classList.add('placeholder');\n if (statsEl) statsEl.hidden = true;\n } else {\n nmEl.textContent = p.name;\n nmEl.classList.remove('placeholder');\n const ct = (window._countBatchesForProd ? window._countBatchesForProd(p.id) : 0);\n if (batchesEl) batchesEl.textContent = ct;\n if (statsEl) statsEl.hidden = false;\n }\n}\n\n// 保留旧函数名 alias (兼容旧 call site)\nfunction renderSelectedProds() {\n renderProdSpace();\n updateCurProdHeader();\n const p = state.selectedProd ? PRODUCTS.find(x => x.id === state.selectedProd) : null;\n document.getElementById('pv-prod').textContent = p ? p.name : '未选择';\n updateCost();\n}\n\n// ─── 商品库全屏弹窗 (单选) ───\nlet _plDraft = null; // string | null\nlet _plCatFilter = '';\nlet _plQuery = '';\n\nfunction renderProdLib() {\n const grid = document.getElementById('pl-grid');\n let list = PRODUCTS;\n if (_plCatFilter) list = list.filter(p => p.cat === _plCatFilter);\n if (_plQuery) list = list.filter(p => p.name.includes(_plQuery));\n grid.innerHTML = list.map(p => `\n <div class=\"pl-card${_plDraft === p.id ? ' selected' : ''}\" data-id=\"${p.id}\">\n <div class=\"pl-card-actions\">\n <button class=\"pl-act\" type=\"button\" data-edit=\"${p.id}\" title=\"编辑商品\" aria-label=\"编辑\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 20h9\"/><path d=\"M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4 12.5-12.5z\"/></svg>\n </button>\n <button class=\"pl-act danger\" type=\"button\" data-del=\"${p.id}\" title=\"删除商品\" aria-label=\"删除\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg>\n </button>\n </div>\n <div class=\"pl-check\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></div>\n <div class=\"placeholder pl-thumb\"><span class=\"ph-frame\">${p.name}</span></div>\n <div class=\"pl-name\">${p.name}</div>\n <div class=\"pl-meta\">${p.cat} · ${p.meta}</div>\n </div>\n `).join('');\n grid.querySelectorAll('.pl-card').forEach(card => {\n card.addEventListener('click', e => {\n // 点击编辑/删除按钮不切换选中\n if (e.target.closest('[data-edit]') || e.target.closest('[data-del]')) return;\n const id = card.dataset.id;\n // 单选: 选中当前,取消其他\n _plDraft = (_plDraft === id) ? null : id;\n grid.querySelectorAll('.pl-card').forEach(c => c.classList.toggle('selected', c.dataset.id === _plDraft));\n document.getElementById('pl-sel-ct').textContent = _plDraft ? 1 : 0;\n });\n });\n grid.querySelectorAll('[data-edit]').forEach(btn => {\n btn.addEventListener('click', e => {\n e.stopPropagation();\n openEditProductDrawer(btn.dataset.edit);\n });\n });\n grid.querySelectorAll('[data-del]').forEach(btn => {\n btn.addEventListener('click', e => {\n e.stopPropagation();\n const id = btn.dataset.del;\n const p = PRODUCTS.find(x => x.id === id);\n if (!p) return;\n if (!confirm('确认删除「' + p.name + '」?\\n该操作不可撤销,商品下生成的素材记录也会一并清理。')) return;\n // 从 mock 数据移除\n const idx = PRODUCTS.findIndex(x => x.id === id);\n if (idx >= 0) PRODUCTS.splice(idx, 1);\n if (_plDraft === id) _plDraft = null;\n if (state.selectedProd === id) state.selectedProd = null;\n renderProdLib();\n renderSelectedProds();\n Shell.toast('已删除', p.name);\n });\n });\n document.getElementById('pl-sel-ct').textContent = _plDraft ? 1 : 0;\n}\n\n// ─── 编辑商品 drawer (在商品库内 prefill 数据) ───\n// mock 商品扩展属性 (target + bullets),缺失则给默认值\nconst PRODUCT_EXTRA = {\n p1: { target: '熬夜党 · 25-35 岁女性 · 敏感肌', bullets: ['72h 长效补水', '官方授权正品', '通勤补妆神器'], imgs: 6 },\n p2: { target: '通勤党 · 18-30 岁 · 大学生 / 白领', bullets: ['主动降噪 35dB', '蓝牙 5.4 双设备', '32h 续航'], imgs: 6 },\n p3: { target: '加班党 · 独居青年 · 一人食场景', bullets: ['一杯水即可', '原切牛肉块充足', '6 桶大箱装'], imgs: 6 },\n p4: { target: '通勤防晒 · 油皮 / 敏感肌', bullets: ['SPF50+ PA++++', '物理防晒不刺激', '清透不假白'], imgs: 6 },\n p5: { target: '咖啡入门 · 早八党 · 加班族', bullets: ['冷热水即溶', '原产地豆精选', '24 颗精装'], imgs: 6 },\n p6: { target: '小户型 · 健康饮食 · 新手厨房', bullets: ['4L 大容量', '可视玻璃观察', '一键预设'], imgs: 6 },\n p7: { target: '健身房 · 通勤穿搭 · 18-32 岁女性', bullets: ['裸感面料', '高弹收腹', '亲肤透气'], imgs: 6 },\n};\n\n// 渲染商品图片 grid · n 张占位 + 上传按钮\nfunction renderProdImgs(n) {\n const grid = document.getElementById('pcf-imgs');\n const ct = document.getElementById('pcf-imgs-ct');\n if (!grid) return;\n if (ct) ct.textContent = n;\n let html = '';\n for (let i = 0; i < n; i++) {\n html += `<div class=\"thumb\"><span class=\"ph-frame\">1:1</span><button class=\"rm\" type=\"button\" title=\"删除\" data-idx=\"${i}\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"><path d=\"M6 6l12 12M6 18L18 6\"/></svg></button></div>`;\n }\n html += `<div class=\"img-upload\" id=\"pcf-img-add\" title=\"上传图片\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 5v14M5 12h14\"/></svg></div>`;\n grid.innerHTML = html;\n grid.querySelectorAll('.thumb .rm').forEach(btn => {\n btn.addEventListener('click', () => {\n btn.closest('.thumb').remove();\n if (ct) ct.textContent = grid.querySelectorAll('.thumb').length;\n });\n });\n const addBtn = document.getElementById('pcf-img-add');\n if (addBtn) addBtn.addEventListener('click', () => {\n if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('上传图片', '// 演示版暂不支持真实上传');\n });\n}\n\nlet _editingProdId = null;\n\nfunction openEditProductDrawer(id) {\n const p = PRODUCTS.find(x => x.id === id);\n if (!p) return;\n _editingProdId = id;\n // prefill\n document.getElementById('pc-drawer-title').textContent = '编辑商品 · ' + p.name;\n document.getElementById('pcf-name').value = p.name;\n document.getElementById('pcf-cat').value = p.cat;\n const extra = PRODUCT_EXTRA[id] || { target: '', bullets: [], imgs: 0 };\n document.getElementById('pcf-target').value = extra.target || '';\n // 渲染商品图片 (n 张占位)\n renderProdImgs(typeof extra.imgs === 'number' ? extra.imgs : 6);\n // 渲染 bullets\n const ul = document.getElementById('pcf-bullets');\n // 移除除 .add 之外的所有 li\n ul.querySelectorAll('li:not(.add)').forEach(li => li.remove());\n const addLi = ul.querySelector('.add');\n (extra.bullets || []).forEach((b, i) => {\n const li = document.createElement('li');\n li.innerHTML = `\n <span class=\"num\">${i + 1}</span>\n <input value=\"${b.replace(/\"/g, '"')}\">\n <button class=\"rm\" type=\"button\" aria-label=\"删除\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6 6l12 12M6 18L18 6\"/></svg></button>\n `;\n ul.insertBefore(li, addLi);\n li.querySelector('.rm').addEventListener('click', () => {\n li.remove();\n renumberBullets();\n });\n });\n document.getElementById('pcf-add-input').value = '';\n document.getElementById('pc-drawer-bg').classList.add('show');\n document.getElementById('pc-drawer').classList.add('show');\n document.getElementById('pc-drawer').setAttribute('aria-hidden', 'false');\n}\n\nfunction renumberBullets() {\n const ul = document.getElementById('pcf-bullets');\n [...ul.querySelectorAll('li:not(.add) .num')].forEach((s, i) => { s.textContent = i + 1; });\n}\n\nfunction closeEditProductDrawer() {\n document.getElementById('pc-drawer-bg').classList.remove('show');\n document.getElementById('pc-drawer').classList.remove('show');\n document.getElementById('pc-drawer').setAttribute('aria-hidden', 'true');\n _editingProdId = null;\n}\n\n// 新增 bullet · 回车\ndocument.getElementById('pcf-add-input').addEventListener('keydown', e => {\n if (e.key !== 'Enter') return;\n const v = e.target.value.trim();\n if (!v) return;\n const ul = document.getElementById('pcf-bullets');\n const addLi = ul.querySelector('.add');\n const li = document.createElement('li');\n li.innerHTML = `\n <span class=\"num\">0</span>\n <input value=\"${v.replace(/\"/g, '"')}\">\n <button class=\"rm\" type=\"button\" aria-label=\"删除\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6 6l12 12M6 18L18 6\"/></svg></button>\n `;\n ul.insertBefore(li, addLi);\n li.querySelector('.rm').addEventListener('click', () => { li.remove(); renumberBullets(); });\n e.target.value = '';\n renumberBullets();\n});\n\ndocument.getElementById('pc-drawer-close').addEventListener('click', closeEditProductDrawer);\ndocument.getElementById('pc-cancel-btn').addEventListener('click', closeEditProductDrawer);\ndocument.getElementById('pc-drawer-bg').addEventListener('click', closeEditProductDrawer);\ndocument.getElementById('pc-save-btn').addEventListener('click', () => {\n if (!_editingProdId) return;\n const newName = document.getElementById('pcf-name').value.trim();\n const newCat = document.getElementById('pcf-cat').value;\n const newTarget = document.getElementById('pcf-target').value.trim();\n if (!newName) { Shell.toast('请填写商品名称'); return; }\n // 写回 PRODUCTS\n const p = PRODUCTS.find(x => x.id === _editingProdId);\n if (p) { p.name = newName; p.cat = newCat; }\n // 写回 PRODUCT_EXTRA (含 imgs 数量)\n const bullets = [...document.querySelectorAll('#pcf-bullets li:not(.add) input')].map(i => i.value.trim()).filter(Boolean);\n const imgs = document.querySelectorAll('#pcf-imgs .thumb').length;\n PRODUCT_EXTRA[_editingProdId] = { target: newTarget, bullets, imgs };\n Shell.toast('已保存', newName);\n closeEditProductDrawer();\n renderProdLib();\n renderSelectedProds();\n});\n\n// 全部商品 入口 (左侧栏底部 · 打开商品库 modal)\nfunction openProdLibModal() {\n _plDraft = state.selectedProd;\n _plCatFilter = '';\n _plQuery = '';\n document.getElementById('pl-search-input').value = '';\n document.querySelectorAll('.pl-side-item').forEach(x => x.classList.toggle('active', x.dataset.cat === ''));\n renderProdLib();\n document.getElementById('pl-modal-bg').classList.add('show');\n}\ndocument.getElementById('ps-all-btn').addEventListener('click', openProdLibModal);\n\n// 商品空间 · 搜索框 · 新建按钮\ndocument.getElementById('ps-search-input').addEventListener('input', e => {\n _psQuery = e.target.value;\n renderProdSpace();\n});\ndocument.getElementById('ps-new-btn').addEventListener('click', () => {\n if (!window.NewProductDrawer) { Shell.toast('Drawer 未加载'); return; }\n window.NewProductDrawer.open({\n onSave: function (p) {\n const product = {\n id: p.id,\n name: p.name,\n cat: p.cat,\n meta: (p.target ? p.target.split(/[ ,、、]+/)[0] : '新建') + ' · ' + p.imgs + ' 张图',\n };\n PRODUCTS.unshift(product);\n renderProdSpace();\n selectProduct(product.id);\n Shell.toast('已加入商品库', '+ ' + product.name);\n }\n });\n});\ndocument.getElementById('pl-close-btn').addEventListener('click', () => {\n document.getElementById('pl-modal-bg').classList.remove('show');\n});\ndocument.getElementById('pl-cancel-btn').addEventListener('click', () => {\n document.getElementById('pl-modal-bg').classList.remove('show');\n});\ndocument.getElementById('pl-confirm-btn').addEventListener('click', () => {\n if (!_plDraft) { Shell.toast('请先选择商品', '只能选 1 个'); return; }\n document.getElementById('pl-modal-bg').classList.remove('show');\n selectProduct(_plDraft);\n});\ndocument.getElementById('pl-new-btn').addEventListener('click', () => {\n if (!window.NewProductDrawer) { Shell.toast('Drawer 未加载'); return; }\n // 商品库保持 open(drawer z-index 1101 > pl-modal-bg 998 会覆盖之上)\n window.NewProductDrawer.open({\n onSave: function (p) {\n // 把新商品注入本页 PRODUCTS,刷新商品库 + 已选列表\n const product = {\n id: p.id,\n name: p.name,\n cat: p.cat,\n meta: (p.target ? p.target.split(/[ ,、、]+/)[0] : '新建') + ' · ' + p.imgs + ' 张图',\n };\n PRODUCTS.unshift(product);\n // 单选: 新建商品直接选中(覆盖原选)\n _plDraft = product.id;\n // 强制 reset filter/query,保证新商品在首位可见\n _plCatFilter = '';\n _plQuery = '';\n const searchInput = document.getElementById('pl-search-input');\n if (searchInput) searchInput.value = '';\n renderProdLib();\n renderProdSpace();\n selectProduct(product.id);\n Shell.toast('已加入商品库', '+ ' + product.name);\n }\n });\n});\ndocument.querySelectorAll('.pl-side-item').forEach(item => {\n item.addEventListener('click', () => {\n document.querySelectorAll('.pl-side-item').forEach(x => x.classList.remove('active'));\n item.classList.add('active');\n _plCatFilter = item.dataset.cat;\n renderProdLib();\n });\n});\ndocument.getElementById('pl-search-input').addEventListener('input', e => {\n _plQuery = e.target.value.trim();\n renderProdLib();\n});\n\n// ─── 模特选择 (单选) ───\nfunction updateModelSummary() {\n const id = state.selectedModel;\n const card = id ? document.querySelector('.model-card[data-id=\"' + id + '\"]') : null;\n const name = card ? card.dataset.name : '';\n document.getElementById('pv-model').textContent = name\n ? name + ' (亚洲·25岁·清新)'\n : '未选择';\n updateCost();\n}\nfunction updateCost() {\n const hasProd = !!state.selectedProd;\n const hasModel = !!state.selectedModel;\n const total = (hasProd && hasModel ? 1 : 0) * state.count * UNIT_PRICE;\n document.getElementById('cost-total').textContent = '¥' + total.toFixed(2);\n const btn = document.getElementById('mp-go-btn');\n if (!hasProd || !hasModel) btn.classList.add('disabled');\n else btn.classList.remove('disabled');\n}\nfunction selectModel(id) {\n state.selectedModel = (state.selectedModel === id) ? null : id;\n renderModelMini();\n // 同步所有出现的 model-card (lib grid 里的)\n document.querySelectorAll('.ml-grid .model-card').forEach(c =>\n c.classList.toggle('selected', c.dataset.id === state.selectedModel)\n );\n updateModelSummary();\n}\n\n/* mini grid · 动态渲染 · 选中的模特(如不在默认 4 张里)会顶替到首位 */\nconst MINI_DEFAULT_IDS = ['m1','m2','m3','m4'];\nfunction renderModelMini() {\n const grid = document.getElementById('model-grid-mini');\n if (!grid) return;\n const selId = state.selectedModel;\n let ids;\n if (!selId || MINI_DEFAULT_IDS.includes(selId)) {\n ids = MINI_DEFAULT_IDS.slice();\n } else {\n ids = [selId, ...MINI_DEFAULT_IDS.slice(0, 3)];\n }\n grid.innerHTML = ids.map(id => {\n const m = MODELS.find(x => x.id === id);\n if (!m) return '';\n const isSelected = m.id === selId;\n return `\n <div class=\"model-card${isSelected ? ' selected' : ''}\" data-id=\"${m.id}\" data-name=\"${m.name}\">\n <div class=\"placeholder m-thumb\"><span class=\"ph-frame\">${m.name}</span></div>\n <div class=\"m-name\">${m.name}</div>\n <div class=\"m-tag\">${m.gender}·${m.age}·${m.style}</div>\n </div>\n `;\n }).join('');\n grid.querySelectorAll('.model-card').forEach(card => {\n card.addEventListener('click', e => {\n if (e.target.closest('.m-thumb')) {\n openModelDetail(card.dataset.id);\n return;\n }\n selectModel(card.dataset.id);\n });\n });\n}\n// 注:首次 renderModelMini() 调用挪到 MODELS 声明之后,避免 TDZ\n// ─── 立即生成设置 ───\ndocument.querySelectorAll('.pill-row').forEach(row => {\n row.addEventListener('click', e => {\n const btn = e.target.closest('.opt');\n if (!btn) return;\n row.querySelectorAll('.opt').forEach(o => o.classList.remove('active'));\n btn.classList.add('active');\n const key = row.dataset.key;\n state[key] = isNaN(+btn.dataset.val) ? btn.dataset.val : +btn.dataset.val;\n if (key === 'count') document.getElementById('pv-count').textContent = btn.dataset.val;\n if (key === 'ratio') document.getElementById('pv-ratio').textContent = btn.dataset.val;\n updateCost();\n });\n});\n\n// ─── 预览区空态 / 内容 切换 ───\nfunction showPreviewEmpty() {\n const empty = document.getElementById('pv-empty');\n const sum = document.getElementById('pv-summary');\n const grid = document.getElementById('pv-grid');\n const foot = document.getElementById('pv-foot');\n if (empty) empty.hidden = false;\n if (sum) sum.hidden = true;\n if (grid) grid.hidden = true;\n if (foot) foot.hidden = true;\n}\nfunction showPreviewContent() {\n const empty = document.getElementById('pv-empty');\n const sum = document.getElementById('pv-summary');\n const grid = document.getElementById('pv-grid');\n const foot = document.getElementById('pv-foot');\n if (empty) empty.hidden = true;\n if (sum) sum.hidden = false;\n if (grid) grid.hidden = false;\n if (foot) foot.hidden = false;\n // pv-batch 由 renderResultCards 单独控制\n}\n\n// ─── 立即生成 + 生成结果交互 (hover 重跑/采用 + 批量) ───\nconst RERUN_SVG = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"23 4 23 10 17 10\"/><polyline points=\"1 20 1 14 7 14\"/><path d=\"M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15\"/></svg>';\nconst ADOPT_SVG = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"/></svg>';\n\nlet _batchSeq = 0;\nconst CELL_RERUN_SVG = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5\"/></svg>';\nconst CELL_DL_SVG = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3\"/></svg>';\nconst CELL_MORE_SVG = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"5\" cy=\"12\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"12\" cy=\"12\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"19\" cy=\"12\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/></svg>';\nconst CELL_ADOPT_SVG = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z\"/></svg>';\nconst CELL_DEL_SVG = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg>';\nconst CELL_EDIT_SVG = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 20h9\"/><path d=\"M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4z\"/></svg>';\n\nfunction buildResultCard(label) {\n const div = document.createElement('div');\n div.className = 'mp-result gen';\n div.innerHTML = `\n <div class=\"mp-r-thumb\"><span class=\"ph-frame\">${label || state.ratio}</span></div>\n <span class=\"adopt-badge\">已采用</span>\n <div class=\"cell-feedback\" aria-hidden=\"true\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"/></svg>\n <span>已采用</span>\n </div>\n <div class=\"cell-ops\">\n <button class=\"r-rerun\" type=\"button\" title=\"再次生成\" aria-label=\"再次生成\">${CELL_RERUN_SVG}</button>\n <button class=\"r-dl\" type=\"button\" title=\"下载\" aria-label=\"下载\">${CELL_DL_SVG}</button>\n <div class=\"cell-more-wrap\">\n <button class=\"r-more\" type=\"button\" title=\"更多\" aria-label=\"更多\">${CELL_MORE_SVG}</button>\n <div class=\"cell-more-menu\">\n <button class=\"r-adopt\" type=\"button\">${CELL_ADOPT_SVG}<span>加入资产库</span></button>\n <button class=\"r-del danger\" type=\"button\">${CELL_DEL_SVG}<span>删除</span></button>\n </div>\n </div>\n </div>\n `;\n // 模拟 ~1.2s 后切到 ok 状态\n setTimeout(() => {\n if (div.classList.contains('gen')) div.classList.remove('gen');\n }, 1200);\n div.querySelector('.r-rerun').addEventListener('click', e => { e.stopPropagation(); rerunOne(div); });\n div.querySelector('.r-dl').addEventListener('click', e => {\n e.stopPropagation();\n Shell.toast('下载', '已开始下载 · MOCK');\n });\n // 更多 menu 开/合\n const moreBtn = div.querySelector('.r-more');\n const moreWrap = div.querySelector('.cell-more-wrap');\n moreBtn.addEventListener('click', e => {\n e.stopPropagation();\n const willOpen = !moreWrap.classList.contains('open');\n document.querySelectorAll('.mp-result .cell-more-wrap.open').forEach(w => w.classList.remove('open'));\n if (willOpen) moreWrap.classList.add('open');\n });\n div.querySelector('.r-adopt').addEventListener('click', e => {\n e.stopPropagation();\n moreWrap.classList.remove('open');\n adoptOne(div);\n });\n div.querySelector('.r-del').addEventListener('click', e => {\n e.stopPropagation();\n moreWrap.classList.remove('open');\n const batch = div.closest('.mp-result-batch');\n div.remove();\n if (batch) {\n // 如果该批次空了, 整批移除\n if (!batch.querySelectorAll('.mp-result:not(.placeholder-only)').length) batch.remove();\n else updateBatchSummary();\n }\n Shell.toast('已删除');\n });\n return div;\n}\nfunction appendBatch(n, kind) {\n const grid = document.getElementById('pv-grid');\n _batchSeq += 1;\n const batch = document.createElement('div');\n batch.className = 'mp-result-batch';\n batch.dataset.kind = kind;\n const ts = new Date();\n const tsStr = `${String(ts.getHours()).padStart(2,'0')}:${String(ts.getMinutes()).padStart(2,'0')}:${String(ts.getSeconds()).padStart(2,'0')}`;\n const labCls = kind === 'gen' ? 'gen' : 'rerun';\n const labTxt = kind === 'gen' ? `批次 ${_batchSeq} · 初始生成` : (kind === 'rerun-all' ? `批次 ${_batchSeq} · 全部重跑` : `批次 ${_batchSeq} · 单张重跑`);\n const _curModel = state.selectedModel ? MODELS.find(x => x.id === state.selectedModel) : null;\n batch.dataset.ts = String(ts.getTime());\n batch.dataset.modelId = _curModel ? _curModel.id : '';\n batch.dataset.modelName = _curModel ? _curModel.name : '';\n batch.dataset.ratio = state.ratio || '';\n batch.dataset.search = [\n labTxt, _curModel ? _curModel.name : '',\n _curModel ? _curModel.style : '',\n state.ratio || '', n + '张'\n ].join(' ').toLowerCase();\n batch.innerHTML = `\n <div class=\"mp-batch-head\">\n <span class=\"lab ${labCls}\">${labTxt}</span>\n <span class=\"sep\">·</span>\n <span>${n} 张 · ${state.ratio}</span>\n ${_curModel ? `<span class=\"sep\">·</span><span>${_curModel.name}</span>` : ''}\n <span class=\"sep\">·</span>\n <span>${tsStr}</span>\n </div>\n <div class=\"mp-result-grid\"></div>\n <div class=\"mp-pv-batch batch-foot\">\n <button class=\"pill-btn edit-batch\" type=\"button\" title=\"重新编辑\">\n ${CELL_EDIT_SVG}\n <span>重新编辑</span>\n </button>\n <button class=\"pill-btn rerun-batch\" type=\"button\" title=\"再次生成这一批\">\n ${CELL_RERUN_SVG}\n <span>再次生成</span>\n </button>\n <div class=\"batch-more-wrap\">\n <button class=\"pill-btn icon batch-more\" type=\"button\" title=\"更多\" aria-label=\"更多\">${CELL_MORE_SVG}</button>\n <div class=\"batch-more-menu\" role=\"menu\">\n <button class=\"batch-save-all\" type=\"button\">${CELL_ADOPT_SVG}<span>全部加入资产库</span></button>\n <button class=\"batch-del danger\" type=\"button\">${CELL_DEL_SVG}<span>删除该批结果</span></button>\n </div>\n </div>\n </div>\n `;\n const gridInner = batch.querySelector('.mp-result-grid');\n for (let i = 0; i < n; i++) gridInner.appendChild(buildResultCard());\n batch.querySelector('.edit-batch').addEventListener('click', () => {\n // 跳回左侧表单(模特/数量/比例) · 与图片创作「重新编辑」语义对齐\n const form = document.querySelector('.mp-form');\n if (form) form.scrollIntoView({ behavior: 'smooth', block: 'start' });\n Shell.toast('重新编辑', '请在左侧调整模特 / 比例 / 数量后再生成');\n });\n batch.querySelector('.rerun-batch').addEventListener('click', () => {\n appendBatch(n, 'rerun-all');\n Shell.toast('再次生成', n + ' 张图重新生成中 · 新批次已追加');\n });\n // 提供 _adoptAll 内联函数 (给 更多 menu 的「全部加入资产库」复用)\n const _adoptAll = () => {\n const cards = batch.querySelectorAll('.mp-result:not(.adopted)');\n if (!cards.length) { Shell.toast('该批次已全部采用'); return; }\n cards.forEach(c => { c.classList.remove('gen'); c.classList.add('adopted'); });\n updateBatchSummary();\n Shell.toast('已全部加入资产库', cards.length + ' 张图入对应商品的 AI 素材 · 扣 ¥' + (cards.length * UNIT_PRICE).toFixed(2));\n };\n // 批次「更多」按钮 → 开/合 menu\n const _bMoreBtn = batch.querySelector('.batch-more');\n const _bMoreWrap = batch.querySelector('.batch-more-wrap');\n if (_bMoreBtn && _bMoreWrap) {\n _bMoreBtn.addEventListener('click', e => {\n e.stopPropagation();\n const willOpen = !_bMoreWrap.classList.contains('open');\n document.querySelectorAll('.mp-pv-batch .batch-more-wrap.open').forEach(w => w.classList.remove('open'));\n if (willOpen) _bMoreWrap.classList.add('open');\n });\n }\n batch.querySelector('.batch-save-all').addEventListener('click', e => {\n e.stopPropagation();\n if (_bMoreWrap) _bMoreWrap.classList.remove('open');\n _adoptAll();\n });\n batch.querySelector('.batch-del').addEventListener('click', e => {\n e.stopPropagation();\n if (_bMoreWrap) _bMoreWrap.classList.remove('open');\n batch.remove();\n updateBatchSummary();\n Shell.toast('已删除该批结果');\n });\n grid.appendChild(batch);\n batch.scrollIntoView({ behavior: 'smooth', block: 'end' });\n updateBatchSummary();\n if (typeof _refreshModelMenu === 'function') _refreshModelMenu();\n if (typeof applyPvFilters === 'function') applyPvFilters();\n}\nfunction renderResultCards(n) {\n const grid = document.getElementById('pv-grid');\n // 首次生成:清掉默认 placeholder-batch 占位,但保留已有真实批次\n // 再次「立即生成」(用户换了设置):追加新批次到底部,不再覆盖\n grid.querySelectorAll('.placeholder-batch').forEach(el => el.remove());\n appendBatch(n, 'gen');\n}\n\nfunction rerunOne(card) {\n if (!card) return;\n appendBatch(1, 'rerun-one');\n}\nfunction adoptOne(card) {\n if (!card || card.classList.contains('adopted')) return;\n card.classList.remove('gen');\n card.classList.add('adopted');\n // spec §4.18 · 就地中央反馈 (替代全局 toast,用户不必转头看屏幕角落)\n card.classList.add('show-feedback');\n setTimeout(() => card.classList.remove('show-feedback'), 1500);\n updateBatchSummary();\n}\n// 点击页面其它位置 → 关闭单图/批次 more menu\ndocument.addEventListener('click', e => {\n if (!e.target.closest('.mp-result .cell-more-wrap')) {\n document.querySelectorAll('.mp-result .cell-more-wrap.open').forEach(w => w.classList.remove('open'));\n }\n if (!e.target.closest('.mp-pv-batch .batch-more-wrap')) {\n document.querySelectorAll('.mp-pv-batch .batch-more-wrap.open').forEach(w => w.classList.remove('open'));\n }\n});\nfunction updateBatchSummary() {\n // 逐批次更新「全部采用 · 已采用/总数」\n document.querySelectorAll('#pv-grid .mp-result-batch').forEach(batch => {\n const cards = batch.querySelectorAll('.mp-result:not(.placeholder-only)');\n const adopted = batch.querySelectorAll('.mp-result.adopted').length;\n const adoptedEl = batch.querySelector('.adopt-batch .adopted');\n const totalEl = batch.querySelector('.adopt-batch .total');\n if (adoptedEl) adoptedEl.textContent = adopted;\n if (totalEl) totalEl.textContent = cards.length;\n });\n}\n\ndocument.getElementById('mp-go-btn').addEventListener('click', () => {\n if (!state.selectedModel || !state.selectedProd) return;\n const grid = document.getElementById('pv-grid');\n const hasReal = grid && grid.querySelector('.mp-result-batch:not(.placeholder-batch)');\n const prod = PRODUCTS.find(p => p.id === state.selectedProd);\n if (hasReal) {\n Shell.toast('已追加批次', state.count + ' 张图新增到下方 · 旧批次保留');\n } else {\n Shell.toast('已提交任务', (prod ? prod.name + ' · ' : '') + state.count + ' 张图生成中');\n }\n showPreviewContent();\n renderResultCards(state.count);\n});\n\n// 批量按钮已下沉到每个批次内部 (.rerun-batch / .adopt-batch)\n// 不再有全局 #pv-rerun-all / #pv-adopt-all\n\n// ============================================================\n// 工具台头部 · 搜索 / 时间 / 模特 筛选\n// ============================================================\nconst _pvFilter = { time: 'all', model: 'all', search: '' };\n\nfunction _pvTimeMatch(ts, key) {\n if (key === 'all') return true;\n const now = Date.now();\n const diff = now - Number(ts);\n if (key === '10min') return diff <= 10 * 60 * 1000;\n if (key === '1h') return diff <= 60 * 60 * 1000;\n if (key === 'today') {\n const a = new Date(now); const b = new Date(Number(ts));\n return a.toDateString() === b.toDateString();\n }\n return true;\n}\n\nfunction applyPvFilters() {\n const grid = document.getElementById('pv-grid');\n if (!grid) return;\n const q = (_pvFilter.search || '').trim().toLowerCase();\n let visible = 0;\n grid.querySelectorAll('.mp-result-batch:not(.placeholder-batch)').forEach(batch => {\n let ok = true;\n if (!_pvTimeMatch(batch.dataset.ts, _pvFilter.time)) ok = false;\n if (ok && _pvFilter.model !== 'all' && batch.dataset.modelId !== _pvFilter.model) ok = false;\n if (ok && q && !(batch.dataset.search || '').includes(q)) ok = false;\n batch.dataset.hidden = ok ? '0' : '1';\n if (ok) visible += 1;\n });\n // 占位批次:只有当无真实批次 & 无 active filter 时显示\n const hasReal = !!grid.querySelector('.mp-result-batch:not(.placeholder-batch)');\n const filterActive = _pvFilter.time !== 'all' || _pvFilter.model !== 'all' || q.length > 0;\n grid.querySelectorAll('.placeholder-batch').forEach(ph => {\n ph.dataset.hidden = (hasReal || filterActive) ? '1' : '0';\n });\n}\n\nfunction _refreshModelMenu() {\n const menu = document.getElementById('mp-menu-model');\n if (!menu) return;\n const grid = document.getElementById('pv-grid');\n const used = new Map();\n if (grid) {\n grid.querySelectorAll('.mp-result-batch:not(.placeholder-batch)').forEach(b => {\n const id = b.dataset.modelId; const nm = b.dataset.modelName;\n if (id) used.set(id, nm || id);\n });\n }\n const items = ['<button class=\"tb-menu-item' + (_pvFilter.model === 'all' ? ' active' : '') + '\" type=\"button\" data-val=\"all\">全部模特</button>'];\n if (used.size === 0) {\n items.push('<div class=\"tb-menu-empty\">暂无批次,生成后可按模特筛选</div>');\n } else {\n used.forEach((nm, id) => {\n items.push(`<button class=\"tb-menu-item${_pvFilter.model === id ? ' active' : ''}\" type=\"button\" data-val=\"${id}\">${nm}</button>`);\n });\n }\n menu.innerHTML = items.join('');\n}\n\nfunction _setChipLabel(chipId, baseLabel, val, valText) {\n const lbl = document.querySelector('#' + chipId + ' .lbl');\n const chip = document.getElementById(chipId);\n if (!lbl || !chip) return;\n if (val === 'all' || !val) {\n lbl.textContent = baseLabel;\n chip.classList.remove('active');\n } else {\n lbl.textContent = baseLabel;\n chip.classList.add('active');\n // 触发 ::after ':' 伪元素显示已选项\n chip.title = baseLabel + ':' + valText;\n }\n}\n\nfunction _closeAllMenus(except) {\n document.querySelectorAll('.mp-main-h .tb-menu-wrap.open').forEach(w => {\n if (w !== except) w.classList.remove('open');\n });\n}\n\n// chip 点击 → 开/合菜单\ndocument.querySelectorAll('.mp-main-h .tb-menu-wrap').forEach(wrap => {\n const chip = wrap.querySelector('.tb-chip');\n chip.addEventListener('click', e => {\n e.stopPropagation();\n const willOpen = !wrap.classList.contains('open');\n _closeAllMenus(wrap);\n wrap.classList.toggle('open', willOpen);\n if (willOpen && wrap.dataset.filter === 'model') _refreshModelMenu();\n });\n});\n// 菜单项 → 选中并应用\ndocument.querySelectorAll('#mp-menu-time, #mp-menu-model').forEach(menu => {\n menu.addEventListener('click', e => {\n const btn = e.target.closest('.tb-menu-item');\n if (!btn) return;\n const val = btn.dataset.val;\n const txt = btn.textContent.trim();\n const wrap = menu.closest('.tb-menu-wrap');\n const key = wrap.dataset.filter;\n _pvFilter[key] = val;\n menu.querySelectorAll('.tb-menu-item').forEach(it => it.classList.toggle('active', it === btn));\n wrap.classList.remove('open');\n const baseLabel = key === 'time' ? '时间' : '模特';\n _setChipLabel(key === 'time' ? 'mp-chip-time' : 'mp-chip-model', baseLabel, val, txt);\n applyPvFilters();\n });\n});\n// 点击页面其它位置关闭菜单\ndocument.addEventListener('click', e => {\n if (!e.target.closest('.mp-main-h .tb-menu-wrap')) _closeAllMenus(null);\n});\n\n// 搜索 toggle + 输入\n(function setupSearch() {\n const wrap = document.getElementById('mp-search-wrap');\n const toggle = document.getElementById('mp-search-toggle');\n const input = document.getElementById('mp-search-input');\n if (!wrap || !toggle || !input) return;\n toggle.addEventListener('click', e => {\n e.stopPropagation();\n const willExpand = !wrap.classList.contains('expanded');\n wrap.classList.toggle('expanded', willExpand);\n if (willExpand) setTimeout(() => input.focus(), 50);\n else {\n input.value = '';\n _pvFilter.search = '';\n applyPvFilters();\n }\n });\n input.addEventListener('input', () => {\n _pvFilter.search = input.value;\n applyPvFilters();\n });\n input.addEventListener('keydown', e => {\n if (e.key === 'Escape') {\n input.value = '';\n _pvFilter.search = '';\n wrap.classList.remove('expanded');\n applyPvFilters();\n }\n });\n})();\n\n// ─── 模特库 全屏 ───\nconst MODELS = [\n { id: 'm1', name: 'Ava', gender: '女', age: '青年', style: '清新自然', source: 'preset', used: 12, region: '东亚', skin: '白皙', height: '中等', build: '标准', hairLen: '长发', hairColor: '黑', vibe: '温柔', feature: '邻家女孩气质,微笑亲和' },\n { id: 'm2', name: 'Luna', gender: '女', age: '青年', style: '学生少女', source: 'preset', used: 8, region: '东亚', skin: '白皙', height: '偏小', build: '纤细', hairLen: '中发', hairColor: '深棕', vibe: '甜美', feature: '校园风,书卷气重' },\n { id: 'm3', name: 'Mia', gender: '女', age: '青年', style: 'OL 通勤', source: 'preset', used: 5, region: '东亚', skin: '小麦', height: '中等', build: '标准', hairLen: '短发', hairColor: '黑', vibe: '干练', feature: '都市职场气场,锐利眼神' },\n { id: 'm4', name: 'Zoe', gender: '女', age: '青年', style: '健身运动', source: 'preset', used: 9, region: '东亚', skin: '健康', height: '偏高', build: '运动', hairLen: '中发', hairColor: '栗色', vibe: '活力', feature: '马尾辫,健身房常客' },\n { id: 'm5', name: 'Iris', gender: '女', age: '中年', style: '都市精致', source: 'preset', used: 3, region: '东亚', skin: '白皙', height: '中等', build: '标准', hairLen: '中发', hairColor: '酒红', vibe: '优雅', feature: '熟女气场,精致妆容' },\n { id: 'm6', name: 'Lily', gender: '女', age: '青年', style: '甜美韩系', source: 'preset', used: 7, region: '东亚', skin: '白皙', height: '偏小', build: '纤细', hairLen: '长发', hairColor: '浅棕', vibe: '甜美', feature: '韩系混血感,微卷长发' },\n { id: 'm7', name: 'Sora', gender: '女', age: '青年', style: '日系简约', source: 'preset', used: 6, region: '东亚', skin: '白皙', height: '中等', build: '纤细', hairLen: '短发', hairColor: '黑', vibe: '清冷', feature: '日系氛围感,齐刘海' },\n { id: 'm8', name: 'Eden', gender: '男', age: '青年', style: '商务通勤', source: 'preset', used: 4, region: '东亚', skin: '健康', height: '偏高', build: '标准', hairLen: '短发', hairColor: '黑', vibe: '稳重', feature: '商务精英范,西装常驻' },\n { id: 'm9', name: 'Kai', gender: '男', age: '青年', style: '街头潮流', source: 'preset', used: 5, region: '东亚', skin: '小麦', height: '中等', build: '运动', hairLen: '中发', hairColor: '亚麻', vibe: '潮酷', feature: '街头潮人,鼻钉耳骨钉' },\n { id: 'm10', name: 'Leo', gender: '男', age: '中年', style: '熟男品质', source: 'preset', used: 2, region: '东亚', skin: '健康', height: '偏高', build: '标准', hairLen: '短发', hairColor: '微银', vibe: '沉稳', feature: '熟男魅力,胡须利落' },\n { id: 'm11', name: 'YouA', gender: '女', age: '青年', style: '我的模特', source: 'own', used: 0, region: '—', skin: '—', height: '—', build: '—', hairLen: '—', hairColor: '—', vibe: '—', feature: '用户上传素材,未生成特征' },\n { id: 'm12', name: 'YouB', gender: '女', age: '青年', style: '我的模特', source: 'own', used: 0, region: '—', skin: '—', height: '—', build: '—', hairLen: '—', hairColor: '—', vibe: '—', feature: '用户上传素材,未生成特征' },\n];\n\nfunction renderModelLib(filter) {\n const grid = document.getElementById('ml-grid');\n let list = MODELS;\n if (filter.source && filter.source !== 'all') list = list.filter(m => m.source === filter.source);\n if (filter.gender) list = list.filter(m => m.gender === filter.gender);\n if (filter.age) list = list.filter(m => m.age === filter.age);\n\n // 「添加模特」入口卡 · 平台预设是只读素材库,不展示入口\n const uploadCard = (filter.source === 'preset') ? '' : `\n <div class=\"model-card ml-upload-card\" id=\"ml-upload-card\" role=\"button\" tabindex=\"0\" aria-label=\"上传或生成新模特\">\n <div class=\"m-thumb up-thumb\">\n <div class=\"up-plus\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 5v14M5 12h14\"/></svg>\n </div>\n </div>\n <div class=\"m-name\">添加模特</div>\n <div class=\"m-tag\">// AI 生成 / 本地上传</div>\n </div>\n `;\n\n grid.innerHTML = uploadCard + list.map(m => `\n <div class=\"model-card${state.selectedModel === m.id ? ' selected' : ''}\" data-id=\"${m.id}\" data-name=\"${m.name}\">\n <div class=\"placeholder m-thumb\"><span class=\"ph-frame\">${m.name} · ${m.style}</span></div>\n <div class=\"m-name\">${m.name}</div>\n <div class=\"m-tag\">${m.gender}·${m.age}·${m.style}</div>\n </div>\n `).join('');\n\n // 绑定 click (单选) · 排除上传卡片\n grid.querySelectorAll('.model-card:not(.ml-upload-card)').forEach(card => {\n card.addEventListener('click', e => {\n if (e.target.closest('.m-thumb')) {\n openModelDetail(card.dataset.id);\n return;\n }\n selectModel(card.dataset.id);\n });\n });\n\n // 上传卡点击 → 打开选择 modal\n const upCard = grid.querySelector('#ml-upload-card');\n if (upCard) {\n upCard.addEventListener('click', () => openUploadChoice());\n upCard.addEventListener('keydown', e => {\n if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openUploadChoice(); }\n });\n }\n}\n\n/* ─── 添加模特 · 工作台画布(取代原 modal) ─── */\nconst _uploadCanvas = document.getElementById('ml-canvas');\nfunction openUploadChoice() {\n _uploadCanvas.classList.add('show');\n _uploadCanvas.setAttribute('aria-hidden', 'false');\n}\nfunction _closeUploadCanvasNow() {\n _uploadCanvas.classList.remove('show');\n _uploadCanvas.setAttribute('aria-hidden', 'true');\n}\n\n/* 工作台是否「脏」:右侧栏任一字段已有内容 · 直接读 _mcAi / _mcLocal */\nfunction _isWorkbenchDirty() {\n if (!_uploadCanvas.classList.contains('show')) return false;\n if (typeof _mcAi !== 'undefined') {\n if ((_mcAi.name || '').trim()) return true;\n if (_mcAi.portrait) return true;\n if (_mcAi.triVersions && _mcAi.triVersions.length > 0) return true;\n }\n if (typeof _mcLocal !== 'undefined') {\n if ((_mcLocal.name || '').trim()) return true;\n if (_mcLocal.portraits && _mcLocal.portraits.length > 0) return true;\n if (_mcLocal.triVersions && _mcLocal.triVersions.length > 0) return true;\n }\n return false;\n}\n\n/* 工作台离开 · 重置右侧栏状态(供退出 / 跳转后清场) */\nfunction _resetWorkbenchState() {\n if (typeof _mcAi !== 'undefined') {\n if (_mcAi.portrait?.cellEl) _mcAi.portrait.cellEl.classList.remove('selected');\n _mcAi.name = ''; _mcAi.portrait = null;\n _mcAi.triVersions = []; _mcAi.triActiveIdx = -1;\n }\n if (typeof _mcLocal !== 'undefined') {\n _mcLocal.name = ''; _mcLocal.portraits = [];\n _mcLocal.triVersions = []; _mcLocal.triActiveIdx = -1;\n }\n const nameInput = document.getElementById('mc-up-name');\n if (nameInput) nameInput.value = '';\n if (typeof _renderRight === 'function') { try { _renderRight(); } catch(e) {} }\n}\n\n/* 二次确认弹窗 · 通用 · onConfirm 在用户点击「不保存,退出」后执行 */\nconst _leaveBg = document.getElementById('mc-leave-bg');\nconst _leaveBody = document.getElementById('mc-leave-body');\nconst _leaveCancel = document.getElementById('mc-leave-cancel');\nconst _leaveConfirm = document.getElementById('mc-leave-confirm');\nlet _leavePending = null;\nfunction _openLeaveConfirm(mode, onConfirm) {\n if (mode === 'nav') {\n _leaveBody.innerHTML = '工作台已有内容,跳转到其他页面后<b>不会保存</b>。可继续编辑并点「加入模特库」来保留进度。';\n } else {\n _leaveBody.innerHTML = '工作台已有内容,退出后<b>不会保存</b>。可继续编辑并点「加入模特库」来保留进度。';\n }\n _leavePending = onConfirm || null;\n _leaveBg.classList.add('show');\n _leaveBg.setAttribute('aria-hidden', 'false');\n}\nfunction _closeLeaveConfirm() {\n _leaveBg.classList.remove('show');\n _leaveBg.setAttribute('aria-hidden', 'true');\n _leavePending = null;\n}\n_leaveCancel.addEventListener('click', _closeLeaveConfirm);\n_leaveBg.addEventListener('click', e => { if (e.target === _leaveBg) _closeLeaveConfirm(); });\n_leaveConfirm.addEventListener('click', () => {\n const fn = _leavePending;\n _closeLeaveConfirm();\n if (typeof fn === 'function') fn();\n});\n\n/* 关闭工作台 · 脏态先二次确认 */\nfunction closeUploadChoice() {\n if (_isWorkbenchDirty()) {\n _openLeaveConfirm('exit', () => {\n _resetWorkbenchState();\n _closeUploadCanvasNow();\n });\n return;\n }\n _closeUploadCanvasNow();\n}\ndocument.getElementById('ml-canvas-x')?.addEventListener('click', closeUploadChoice);\ndocument.getElementById('ml-canvas-back').addEventListener('click', closeUploadChoice);\ndocument.addEventListener('keydown', e => {\n if (e.key === 'Escape' && _uploadCanvas.classList.contains('show') && !_leaveBg.classList.contains('show')) {\n closeUploadChoice();\n }\n});\n\n/* 全局拦截外链跳转 + 模特库内的离场操作 · 仅工作台展开且脏态时 */\ndocument.addEventListener('click', e => {\n if (!_uploadCanvas.classList.contains('show')) return;\n if (!_isWorkbenchDirty()) return;\n\n // 1) 外页跳转(侧边栏 nav · 面包屑)\n const a = e.target.closest('a[href]');\n if (a) {\n const href = a.getAttribute('href');\n if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;\n e.preventDefault(); e.stopPropagation();\n _openLeaveConfirm('nav', () => {\n _resetWorkbenchState();\n _closeUploadCanvasNow();\n location.href = href;\n });\n return;\n }\n\n // 2) 余额胶囊(inline onclick=location.href='account.html')\n if (e.target.closest('.balance-chip')) {\n e.preventDefault(); e.stopPropagation();\n _openLeaveConfirm('nav', () => {\n _resetWorkbenchState();\n _closeUploadCanvasNow();\n location.href = 'account.html';\n });\n return;\n }\n\n // 3) 模特库 X · 关闭整个模特库 modal(等同退出工作台 + 返回主页)\n if (e.target.closest('#ml-close-btn')) {\n e.preventDefault(); e.stopPropagation();\n _openLeaveConfirm('exit', () => {\n _resetWorkbenchState();\n _closeUploadCanvasNow();\n document.getElementById('ml-modal-bg').classList.remove('show');\n });\n return;\n }\n\n // 4) 模特库左侧「来源」筛选项 · 切换筛选 = 离开工作台回到列表\n const sideItem = e.target.closest('.ml-side .ml-side-item');\n if (sideItem) {\n e.preventDefault(); e.stopPropagation();\n const src = sideItem.dataset.source;\n _openLeaveConfirm('nav', () => {\n _resetWorkbenchState();\n _closeUploadCanvasNow();\n // 应用筛选 + 同步 active\n _libFilter.source = src;\n document.querySelectorAll('.ml-side .ml-side-item').forEach(x =>\n x.classList.toggle('active', x.dataset.source === src));\n renderModelLib(_libFilter);\n });\n return;\n }\n}, true); // capture · 早于 inline onclick\n\n/* 浏览器后退 / 刷新 / 关闭 · 原生 beforeunload 兜底 */\nwindow.addEventListener('beforeunload', e => {\n if (_isWorkbenchDirty()) { e.preventDefault(); e.returnValue = ''; return ''; }\n});\n\n/* ─── 工作台画布 · 左 AI 生成 区 ─── */\nconst _mcInputText = document.getElementById('mc-input-text');\nconst _mcSendBtn = document.getElementById('mc-send-btn');\n_mcInputText?.addEventListener('input', () => {\n _mcSendBtn.disabled = _mcInputText.value.trim().length === 0;\n // textarea 自适应高度 · 与 .io-input 一致 max-height 220\n _mcInputText.style.height = 'auto';\n _mcInputText.style.height = Math.min(_mcInputText.scrollHeight, 220) + 'px';\n});\nconst _MC_SVG = {\n rerun: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5\"/></svg>',\n dl: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3\"/></svg>',\n more: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"5\" cy=\"12\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"12\" cy=\"12\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"19\" cy=\"12\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/></svg>',\n adopt: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z\"/></svg>',\n del: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg>',\n edit: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 20h9\"/><path d=\"M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4L16.5 3.5z\"/></svg>',\n saveAll: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z\"/><polyline points=\"17 21 17 13 7 13 7 21\"/><polyline points=\"7 3 7 8 15 8\"/></svg>',\n};\n\nfunction _mcAppendMsg(prompt, refs) {\n const inner = document.getElementById('mc-stream-inner');\n if (!inner) return;\n // 首次发送 · 移除空态\n const empty = inner.querySelector('.mc-empty');\n if (empty) empty.remove();\n const safe = String(prompt).replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'})[c]);\n const tags = ['3:4', '默认', '4 张'];\n const tagsHtml = tags.map(t => `<span class=\"meta-chip\">${t}</span>`).join('<span class=\"sep\">·</span>');\n const cells = Array.from({ length: 4 }, (_, i) => `\n <div class=\"mc-cell gen\" data-idx=\"${i}\">\n <div class=\"ph-frame\">生成中 · v${i + 1}</div>\n <div class=\"cell-ops\" hidden>\n <button type=\"button\" data-act=\"cell-rerun\" title=\"再次生成\">${_MC_SVG.rerun}</button>\n <button type=\"button\" data-act=\"cell-dl\" title=\"下载\">${_MC_SVG.dl}</button>\n <div class=\"cell-more-wrap\">\n <button type=\"button\" data-act=\"cell-more\" title=\"更多\">${_MC_SVG.more}</button>\n <div class=\"cell-more-menu\" role=\"menu\">\n <button type=\"button\" data-act=\"cell-adopt\">${_MC_SVG.adopt}<span>加入模特库</span></button>\n <button type=\"button\" class=\"danger\" data-act=\"cell-del\">${_MC_SVG.del}<span>删除</span></button>\n </div>\n </div>\n </div>\n </div>`).join('');\n const msg = document.createElement('div');\n msg.className = 'mc-msg';\n msg.innerHTML = `\n <div class=\"mc-msg-prompt\">\n <div class=\"quote\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 21c0-3.5 3-6 6-6s6 2.5 6 6\"/><circle cx=\"9\" cy=\"8\" r=\"4\"/><path d=\"M19 8v6M22 11h-6\"/></svg>\n </div>\n <div class=\"pt\">\n <div class=\"pt-text\">${safe}</div>\n <div class=\"pt-tags\">${tagsHtml}</div>\n </div>\n </div>\n <div class=\"mc-msg-grid\">${cells}</div>\n <div class=\"mc-msg-ops\">\n <button type=\"button\" data-act=\"edit\">${_MC_SVG.edit}重新编辑</button>\n <button type=\"button\" data-act=\"rerun\">${_MC_SVG.rerun}再次生成</button>\n <div class=\"msg-more-wrap\">\n <button type=\"button\" class=\"icon\" data-act=\"msg-more\" title=\"更多\">${_MC_SVG.more}</button>\n <div class=\"msg-more-menu\" role=\"menu\">\n <button type=\"button\" class=\"danger\" data-act=\"msg-del\">${_MC_SVG.del}<span>删除该批结果</span></button>\n </div>\n </div>\n </div>\n `;\n inner.appendChild(msg);\n // 模拟生成完成 · 1.6s 后去掉 .gen 动画并显示版本号 + 显示 cell-ops + 绑定选中点击\n setTimeout(() => {\n msg.querySelectorAll('.mc-cell').forEach((c, i) => {\n c.classList.remove('gen');\n const ph = c.querySelector('.ph-frame');\n if (ph) ph.textContent = '模特 · v' + (i + 1);\n const ops = c.querySelector('.cell-ops');\n if (ops) ops.hidden = false;\n });\n _bindMcCellPick();\n }, 1600);\n _mcBindMsgEvents(msg, prompt);\n // 滚到底部\n const stream = document.getElementById('mc-stream');\n if (stream) stream.scrollTo({ top: stream.scrollHeight, behavior: 'smooth' });\n}\n\nfunction _mcBindMsgEvents(msg, prompt) {\n // 单图 cell-rerun → 重置该 cell 为 gen, 1.2s 后回 ok\n msg.querySelectorAll('[data-act=\"cell-rerun\"]').forEach(b => {\n b.addEventListener('click', e => {\n e.stopPropagation();\n const cell = b.closest('.mc-cell');\n const ops = cell.querySelector('.cell-ops');\n const ph = cell.querySelector('.ph-frame');\n const idx = Number(cell.dataset.idx || 0);\n cell.classList.add('gen');\n cell.classList.remove('selected');\n if (ops) ops.hidden = true;\n if (ph) ph.textContent = '生成中 · v' + (idx + 1);\n setTimeout(() => {\n cell.classList.remove('gen');\n if (ph) ph.textContent = '模特 · v' + (idx + 1);\n if (ops) ops.hidden = false;\n }, 1200 + Math.random() * 600);\n Shell.toast('已重跑', '该图重新生成中');\n });\n });\n msg.querySelectorAll('[data-act=\"cell-dl\"]').forEach(b => {\n b.addEventListener('click', e => { e.stopPropagation(); Shell.toast('下载', '已开始下载 · MOCK'); });\n });\n // 单图更多 menu 开/合\n msg.querySelectorAll('[data-act=\"cell-more\"]').forEach(b => {\n b.addEventListener('click', e => {\n e.stopPropagation();\n const wrap = b.closest('.cell-more-wrap');\n const willOpen = !wrap.classList.contains('open');\n document.querySelectorAll('.mc-cell .cell-more-wrap.open').forEach(w => w.classList.remove('open'));\n if (willOpen) wrap.classList.add('open');\n });\n });\n msg.querySelectorAll('[data-act=\"cell-adopt\"]').forEach(b => {\n b.addEventListener('click', e => {\n e.stopPropagation();\n const wrap = b.closest('.cell-more-wrap');\n if (wrap) wrap.classList.remove('open');\n Shell.toast('已加入模特库', '已添加到「我的上传」');\n });\n });\n msg.querySelectorAll('[data-act=\"cell-del\"]').forEach(b => {\n b.addEventListener('click', e => {\n e.stopPropagation();\n const wrap = b.closest('.cell-more-wrap');\n if (wrap) wrap.classList.remove('open');\n const cell = b.closest('.mc-cell');\n cell.remove();\n if (!msg.querySelectorAll('.mc-cell').length) msg.remove();\n Shell.toast('已删除');\n });\n });\n // 批次操作\n msg.querySelectorAll('[data-act=\"rerun\"]').forEach(b => {\n b.addEventListener('click', e => {\n e.stopPropagation();\n _mcAppendMsg(prompt, []);\n });\n });\n msg.querySelectorAll('[data-act=\"edit\"]').forEach(b => {\n b.addEventListener('click', e => {\n e.stopPropagation();\n const inp = document.getElementById('mc-input-text');\n if (inp) {\n inp.value = prompt;\n inp.focus();\n inp.dispatchEvent(new Event('input'));\n }\n });\n });\n // 批次更多 menu\n msg.querySelectorAll('[data-act=\"msg-more\"]').forEach(b => {\n b.addEventListener('click', e => {\n e.stopPropagation();\n const wrap = b.closest('.msg-more-wrap');\n const willOpen = !wrap.classList.contains('open');\n document.querySelectorAll('.mc-msg-ops .msg-more-wrap.open').forEach(w => w.classList.remove('open'));\n if (willOpen) wrap.classList.add('open');\n });\n });\n msg.querySelectorAll('[data-act=\"msg-del\"]').forEach(b => {\n b.addEventListener('click', e => {\n e.stopPropagation();\n const wrap = b.closest('.msg-more-wrap');\n if (wrap) wrap.classList.remove('open');\n msg.remove();\n Shell.toast('已删除该批结果');\n });\n });\n}\n\n// 全局点击 → 关闭 mc 单图/批次 more menu\ndocument.addEventListener('click', e => {\n if (!e.target.closest('.mc-cell .cell-more-wrap')) {\n document.querySelectorAll('.mc-cell .cell-more-wrap.open').forEach(w => w.classList.remove('open'));\n }\n if (!e.target.closest('.mc-msg-ops .msg-more-wrap')) {\n document.querySelectorAll('.mc-msg-ops .msg-more-wrap.open').forEach(w => w.classList.remove('open'));\n }\n});\n\n_mcSendBtn?.addEventListener('click', () => {\n const txt = _mcInputText.value.trim();\n if (!txt) return;\n _mcAppendMsg(txt, _mcRefList.slice());\n _mcInputText.value = '';\n _mcInputText.style.height = 'auto';\n _mcSendBtn.disabled = true;\n // 清空参考图(image-optimize 同款行为)\n _mcRefList = [];\n _renderMcRefs();\n});\n// Cmd/Ctrl + Enter 发送\n_mcInputText?.addEventListener('keydown', (e) => {\n if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {\n e.preventDefault();\n _mcSendBtn.click();\n }\n});\n// 示例 chip → 填入 textarea\ndocument.querySelectorAll('.mc-empty .examples .ex').forEach(b => {\n b.addEventListener('click', () => {\n _mcInputText.value = b.dataset.ex || b.textContent.trim();\n _mcInputText.dispatchEvent(new Event('input'));\n _mcInputText.focus();\n });\n});\n// + 按钮上传参考图(仅作展示参考,不入库)\nconst _mcAiRefInput = document.getElementById('mc-ai-ref-input');\nconst _mcRefs = document.getElementById('mc-input-refs');\nlet _mcRefList = [];\ndocument.getElementById('mc-add-btn')?.addEventListener('click', () => _mcAiRefInput.click());\n_mcAiRefInput?.addEventListener('change', e => {\n const files = [...(e.target.files || [])].filter(f => /^image\\//.test(f.type));\n files.forEach(f => _mcRefList.push({ name: f.name, url: URL.createObjectURL(f) }));\n e.target.value = '';\n _renderMcRefs();\n});\nfunction _renderMcRefs() {\n if (!_mcRefs) return;\n _mcRefs.classList.toggle('show', _mcRefList.length > 0);\n _mcRefs.innerHTML = _mcRefList.map((r, i) => `\n <div class=\"mc-input-ref\">\n <img src=\"${r.url}\" alt=\"${r.name}\">\n <button class=\"x\" data-idx=\"${i}\" aria-label=\"移除\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M18 6L6 18M6 6l12 12\"/></svg></button>\n </div>`).join('');\n _mcRefs.querySelectorAll('.x').forEach(x => x.addEventListener('click', () => {\n _mcRefList.splice(+x.dataset.idx, 1);\n _renderMcRefs();\n }));\n}\n\n/* ─── 工作台画布 · 右栏 · tab 切换 + 3 模块状态机 ─── */\nlet _mcRightTab = 'ai';\nconst _mcAi = { name: '', portrait: null, triVersions: [], triActiveIdx: -1 }; // portrait: { label, cellEl }\nconst _mcLocal = { name: '', portraits: [], triVersions: [], triActiveIdx: -1 }; // portraits: [{file,url,name,size}]\nfunction _mcCurState() { return _mcRightTab === 'ai' ? _mcAi : _mcLocal; }\nfunction _hasTri(s) { return s.triVersions.length > 0 && s.triActiveIdx >= 0; }\n\n// Tab 切换\ndocument.querySelectorAll('.mc-up-tab').forEach(btn => {\n btn.addEventListener('click', () => {\n _mcRightTab = btn.dataset.tab;\n document.querySelectorAll('.mc-up-tab').forEach(b => b.classList.toggle('active', b === btn));\n document.querySelectorAll('.mc-up-body [data-show]').forEach(el => {\n el.hidden = el.dataset.show !== _mcRightTab;\n });\n document.getElementById('mc-up-name').value = _mcCurState().name;\n _renderRight();\n });\n});\n\n// 模特姓名 输入(按当前 tab 写到对应 state)\nconst _mcUpName = document.getElementById('mc-up-name');\n_mcUpName?.addEventListener('input', () => {\n _mcCurState().name = _mcUpName.value;\n _updateCommit();\n});\n\n/* ─── AI tab:立绘选中态 ─── */\nconst _aiEmpty = document.getElementById('mc-portrait-ai-empty');\nconst _aiPicked = document.getElementById('mc-portrait-ai-picked');\nconst _aiLabel = document.getElementById('mc-portrait-ai-label');\n\nfunction _renderAiPortrait() {\n if (_mcAi.portrait) {\n _aiEmpty.hidden = true;\n _aiPicked.hidden = false;\n _aiLabel.textContent = _mcAi.portrait.label || '模特立绘';\n } else {\n _aiEmpty.hidden = false;\n _aiPicked.hidden = true;\n }\n}\nfunction _setAiPortrait(data) {\n _mcAi.portrait = data;\n _renderAiPortrait();\n _updateTriBtn(); _updateCommit();\n}\nfunction _clearAiPortrait() {\n if (_mcAi.portrait?.cellEl) _mcAi.portrait.cellEl.classList.remove('selected');\n _mcAi.portrait = null;\n // 立绘清空 → 三视图历史作废\n _mcAi.triVersions = []; _mcAi.triActiveIdx = -1;\n _renderAiPortrait();\n _renderTriView();\n _updateTriBtn(); _updateCommit();\n}\ndocument.getElementById('mc-portrait-ai-clear').addEventListener('click', _clearAiPortrait);\n\n/* ─── Local tab:多张立绘 ─── */\nconst _lpDrop = document.getElementById('mc-portrait-local-drop');\nconst _lpInput = document.getElementById('mc-portrait-local-input');\nconst _lpList = document.getElementById('mc-portrait-local-list');\nconst _lpCount = document.getElementById('mc-portrait-local-count');\n\nfunction _renderLocalPortraits() {\n _lpCount.textContent = _mcLocal.portraits.length;\n _lpList.innerHTML = _mcLocal.portraits.map((p, i) => `\n <div class=\"thumb\">\n <img src=\"${p.url}\" alt=\"${p.name}\">\n <button class=\"x\" data-idx=\"${i}\" aria-label=\"移除\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M18 6L6 18M6 6l12 12\"/></svg></button>\n </div>\n `).join('');\n _lpList.querySelectorAll('.x').forEach(x => x.addEventListener('click', () => {\n _mcLocal.portraits.splice(+x.dataset.idx, 1);\n if (_mcLocal.portraits.length === 0) {\n // 立绘全部清空 → 三视图历史作废\n _mcLocal.triVersions = []; _mcLocal.triActiveIdx = -1;\n }\n _renderLocalPortraits();\n _renderTriView();\n _updateTriBtn(); _updateCommit();\n }));\n}\nfunction _lpAdd(files) {\n const imgs = [...(files || [])].filter(f => /^image\\//.test(f.type));\n imgs.forEach(f => _mcLocal.portraits.push({ file: f, url: URL.createObjectURL(f), name: f.name, size: f.size }));\n _renderLocalPortraits();\n _updateTriBtn(); _updateCommit();\n}\n_lpDrop?.addEventListener('click', () => _lpInput.click());\n_lpDrop?.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _lpInput.click(); } });\n_lpInput?.addEventListener('change', e => { _lpAdd(e.target.files); e.target.value = ''; });\n['dragenter', 'dragover'].forEach(ev => _lpDrop?.addEventListener(ev, e => { e.preventDefault(); _lpDrop.classList.add('dragover'); }));\n['dragleave', 'drop'].forEach(ev => _lpDrop?.addEventListener(ev, e => { e.preventDefault(); _lpDrop.classList.remove('dragover'); }));\n_lpDrop?.addEventListener('drop', e => _lpAdd(e.dataTransfer?.files));\n\n/* ─── 三视图模块 · 16:9 占位常驻 · 空态居中覆盖按钮 · 多版本累积可切换 ─── */\nconst _triSec = document.getElementById('mc-triview-sec');\nconst _triResultImg = document.getElementById('mc-triview-result');\nconst _triFrame = document.getElementById('mc-triview-frame');\nconst _triGenBtn = document.getElementById('mc-triview-gen-btn');\nconst _triHint = document.getElementById('mc-triview-hint');\nconst _triOps = document.getElementById('mc-triview-ops');\nconst _triRerunBtn = document.getElementById('mc-triview-rerun');\nconst _triHistory = document.getElementById('mc-triview-history');\nconst _triHistoryRow = document.getElementById('mc-triview-history-row');\nconst _triHistoryCount = document.getElementById('mc-triview-history-count');\nlet _triGenerating = false;\n\nfunction _portraitReady() {\n return _mcRightTab === 'ai' ? !!_mcAi.portrait : _mcLocal.portraits.length > 0;\n}\n\nfunction _renderTriView() {\n const s = _mcCurState();\n const has = _hasTri(s);\n _triSec.classList.toggle('has-result', has);\n\n if (_triGenerating) {\n // 生成中(首次或重跑):主图脉冲 · 隐藏覆盖按钮 · 历史区保留(若已有版本) · 重跑按钮禁用\n _triGenBtn.hidden = true;\n _triHint.hidden = true;\n _triOps.hidden = !has;\n _triHistory.hidden = !has;\n _triFrame.textContent = '三视图生成中…';\n _triResultImg.classList.add('gen');\n _triRerunBtn.disabled = true;\n if (has) _renderTriHistory(s);\n } else if (has) {\n _triGenBtn.hidden = true;\n _triHint.hidden = true;\n _triOps.hidden = false;\n _triHistory.hidden = false;\n const ver = s.triVersions[s.triActiveIdx];\n _triFrame.textContent = `三视图(正/侧/背) · ${ver.label}`;\n _triResultImg.classList.remove('gen');\n _triRerunBtn.disabled = false;\n _renderTriHistory(s);\n } else {\n _triGenBtn.hidden = false;\n _triHint.hidden = false;\n _triOps.hidden = true;\n _triHistory.hidden = true;\n _triFrame.textContent = '三视图(正/侧/背)';\n _triResultImg.classList.remove('gen');\n }\n}\n\nfunction _renderTriHistory(s) {\n _triHistoryCount.textContent = s.triVersions.length;\n _triHistoryRow.innerHTML = s.triVersions.map((ver, i) => `\n <div class=\"h-thumb${i === s.triActiveIdx ? ' active' : ''}\" data-idx=\"${i}\" title=\"${ver.label} · ${ver.ts}${i === s.triActiveIdx ? ' · 当前采用' : ''}\">\n <span class=\"badge\">当前</span>\n <span class=\"v\">${ver.label}</span>\n </div>\n `).join('');\n _triHistoryRow.querySelectorAll('.h-thumb').forEach(el => {\n el.addEventListener('click', () => {\n const idx = Number(el.dataset.idx);\n if (idx === s.triActiveIdx) return;\n s.triActiveIdx = idx;\n _renderTriView(); _updateCommit();\n });\n });\n}\n\nfunction _updateTriBtn() {\n if (_hasTri(_mcCurState())) return; // 已有版本,按钮被 _renderTriView 隐藏\n const ok = _portraitReady() && !_triGenerating;\n _triGenBtn.disabled = !ok;\n _triHint.textContent = _portraitReady()\n ? '// 一键生成正/侧/背 三视图'\n : (_mcRightTab === 'ai' ? '// 先选中左侧 AI 立绘' : '// 先上传至少 1 张立绘');\n}\n\nfunction _startTriGen() {\n if (!_portraitReady() || _triGenerating) return;\n _triGenerating = true;\n const stateAtStart = _mcCurState();\n _renderTriView();\n setTimeout(() => {\n _triGenerating = false;\n const now = new Date();\n const ts = String(now.getHours()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0');\n const label = 'v' + (stateAtStart.triVersions.length + 1);\n stateAtStart.triVersions.push({ ts, label });\n stateAtStart.triActiveIdx = stateAtStart.triVersions.length - 1;\n if (stateAtStart === _mcCurState()) _renderTriView();\n _updateTriBtn(); _updateCommit();\n }, 1600);\n}\n_triGenBtn.addEventListener('click', _startTriGen);\n_triRerunBtn.addEventListener('click', _startTriGen);\n\n/* ─── 整体渲染 + 提交 ─── */\nconst _mcUpCommit = document.getElementById('mc-up-commit');\nconst _mcUpStat = document.getElementById('mc-up-stat');\nfunction _updateCommit() {\n const s = _mcCurState();\n const nameOk = s.name.trim().length > 0;\n const portraitOk = _portraitReady();\n const triOk = _hasTri(s);\n // 三视图改为「可选」: 姓名 + 立绘 即可保存\n const ready = nameOk && portraitOk;\n _mcUpCommit.disabled = !ready;\n if (ready) {\n _mcUpStat.classList.add('ok');\n if (triOk) {\n _mcUpStat.innerHTML = `✓ 已就绪 · <b>${s.name}</b>`;\n } else {\n _mcUpStat.innerHTML = `✓ 可保存 · <b>${s.name}</b> · <span style=\"color:var(--black-alpha-56)\">建议补三视图</span>`;\n }\n } else {\n _mcUpStat.classList.remove('ok');\n const miss = [];\n if (!nameOk) miss.push('姓名');\n if (!portraitOk) miss.push('立绘');\n _mcUpStat.innerHTML = `// 待完成 · <b>${miss.join(' / ')}</b>`;\n }\n}\nfunction _renderRight() {\n _renderAiPortrait();\n _renderLocalPortraits();\n _renderTriView();\n _updateTriBtn();\n _updateCommit();\n}\n_renderRight();\n\nfunction _doMcCommit() {\n const s = _mcCurState();\n const baseName = s.name.trim().slice(0, 12) || 'YouNew';\n const ts = Date.now().toString(36);\n // 持久化:多张立绘(AI tab 1 张 label · Local tab 真实 blob URL N 张)+ 三视图历史版本(可为空)\n const portraits = _mcRightTab === 'ai'\n ? (_mcAi.portrait ? [{ url: '', name: _mcAi.portrait.label || baseName, label: _mcAi.portrait.label || '立绘' }] : [])\n : _mcLocal.portraits.map((p, i) => ({ url: p.url, name: p.name || `${baseName}-${i+1}`, label: `本地 ${i+1}` }));\n const triVersions = s.triVersions.map(v => ({ ts: v.ts, label: v.label }));\n const hasTri = triVersions.length > 0;\n MODELS.unshift({\n id: 'm-up-' + ts,\n name: baseName,\n gender: '女', age: '青年', style: _mcRightTab === 'ai' ? 'AI 生成' : '我的模特',\n source: 'own', used: 0,\n region: '—', skin: '—', height: '—', build: '—',\n hairLen: '—', hairColor: '—', vibe: '—',\n feature: _mcRightTab === 'ai'\n ? (hasTri ? 'AI 生成 · 已含三视图' : 'AI 生成 · 缺三视图')\n : (hasTri ? '用户上传 · 已含三视图' : '用户上传 · 缺三视图'),\n triview: hasTri ? 1 : 0,\n portraits, triVersions,\n });\n // 重置状态\n if (_mcRightTab === 'ai') {\n if (_mcAi.portrait?.cellEl) _mcAi.portrait.cellEl.classList.remove('selected');\n _mcAi.name = ''; _mcAi.portrait = null;\n _mcAi.triVersions = []; _mcAi.triActiveIdx = -1;\n } else {\n _mcLocal.name = ''; _mcLocal.portraits = [];\n _mcLocal.triVersions = []; _mcLocal.triActiveIdx = -1;\n }\n _mcUpName.value = '';\n _renderRight();\n _libFilter.source = 'own';\n document.querySelectorAll('.ml-side .ml-side-item').forEach(x =>\n x.classList.toggle('active', x.dataset.source === 'own'));\n renderModelLib(_libFilter);\n _resetWorkbenchState();\n _closeUploadCanvasNow();\n Shell.toast(\n hasTri ? '已加入模特库' : '已保存 · 缺三视图',\n hasTri\n ? `${baseName} · 来源 ${_mcRightTab === 'ai' ? 'AI 生成' : '我的上传'}`\n : `${baseName} · 后续可在资产详情页补三视图`\n );\n}\n\n// 缺三视图 · 确认弹窗控制\nconst _notriBg = document.getElementById('mc-notri-bg');\nconst _notriSaveBtn = document.getElementById('mc-notri-save');\nconst _notriGenBtn = document.getElementById('mc-notri-gen');\nfunction _openNotriConfirm() { _notriBg.classList.add('show'); _notriBg.setAttribute('aria-hidden', 'false'); }\nfunction _closeNotriConfirm() { _notriBg.classList.remove('show'); _notriBg.setAttribute('aria-hidden', 'true'); }\n_notriBg.addEventListener('click', e => { if (e.target === _notriBg) _closeNotriConfirm(); });\n_notriSaveBtn.addEventListener('click', () => { _closeNotriConfirm(); _doMcCommit(); });\n_notriGenBtn.addEventListener('click', () => {\n _closeNotriConfirm();\n // 滚动到三视图区域并触发生成\n _triSec?.scrollIntoView({ behavior: 'smooth', block: 'center' });\n if (!_triGenBtn.disabled) setTimeout(() => _triGenBtn.click(), 200);\n});\ndocument.addEventListener('keydown', e => {\n if (e.key === 'Escape' && _notriBg.classList.contains('show')) _closeNotriConfirm();\n});\n\n_mcUpCommit?.addEventListener('click', () => {\n if (_mcUpCommit.disabled) return;\n const s = _mcCurState();\n if (!_hasTri(s)) {\n _openNotriConfirm();\n return;\n }\n _doMcCommit();\n});\n\n/* ─── 左侧 .mc-cell 可点击选为立绘(仅 AI tab) ─── */\nfunction _bindMcCellPick() {\n document.querySelectorAll('#mc-stream .mc-cell').forEach(cell => {\n if (cell.dataset.boundPick) return;\n cell.dataset.boundPick = '1';\n // 添加「已选用」徽标\n if (!cell.querySelector('.pick-badge')) {\n const b = document.createElement('span');\n b.className = 'pick-badge';\n b.textContent = '已选用';\n cell.appendChild(b);\n }\n cell.addEventListener('click', () => {\n if (_mcRightTab !== 'ai') {\n Shell.toast('请切到「AI 生成」标签', '只有 AI 模式才能选用左侧立绘');\n return;\n }\n if (cell.classList.contains('gen')) return;\n // 取消选中\n if (cell.classList.contains('selected')) {\n _clearAiPortrait();\n return;\n }\n // 切换 · 清除上一张选中\n document.querySelectorAll('#mc-stream .mc-cell.selected').forEach(c => c.classList.remove('selected'));\n cell.classList.add('selected');\n const label = cell.querySelector('.ph-frame')?.textContent || '模特立绘';\n // 重置三视图(换了立绘要重新生成)\n _mcAi.triVersions = []; _mcAi.triActiveIdx = -1;\n _renderTriView();\n _setAiPortrait({ label, cellEl: cell });\n });\n });\n}\n\n/* 兼容旧 modal:#ml-up-file 仍存在,但当前未使用(保留以防外部调用) */\nconst _uploadFileInput = document.getElementById('ml-up-file');\n\nlet _libFilter = { source: 'all', gender: '', age: '' };\ndocument.getElementById('open-model-lib').addEventListener('click', () => {\n renderModelLib(_libFilter);\n document.getElementById('ml-modal-bg').classList.add('show');\n});\ndocument.getElementById('ml-close-btn').addEventListener('click', () => {\n document.getElementById('ml-modal-bg').classList.remove('show');\n});\n// 左侧来源 click\ndocument.querySelectorAll('.ml-side .ml-side-item').forEach(item => {\n item.addEventListener('click', () => {\n document.querySelectorAll('.ml-side-item[data-source]').forEach(x => x.classList.remove('active'));\n item.classList.add('active');\n _libFilter.source = item.dataset.source;\n renderModelLib(_libFilter);\n });\n});\n// 顶部 chip 性别/年龄 click\ndocument.querySelectorAll('.ml-toolbar .chip-group').forEach(group => {\n group.addEventListener('click', e => {\n const chip = e.target.closest('.chip');\n if (!chip) return;\n group.querySelectorAll('.chip').forEach(c => c.classList.remove('active'));\n chip.classList.add('active');\n const k = group.dataset.key;\n _libFilter[k] = chip.dataset.val;\n renderModelLib(_libFilter);\n });\n});\n\n// ─── 模特详情 居中弹窗 ───\nlet _detailModelId = null;\nfunction _mdHash(s) { let h = 0; for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0; return Math.abs(h); }\nfunction openModelDetail(id) {\n const m = MODELS.find(x => x.id === id);\n if (!m) return;\n _detailModelId = id;\n const sourceLabel = m.source === 'preset' ? '平台预设' : '我的上传';\n const seed = _mdHash(m.name);\n const assetId = 'ASSET-20240520-M' + String(seed % 1000).padStart(3, '0');\n const fileSize = (4 + (seed % 100) / 10).toFixed(1) + 'MB';\n const fav = String(8 + seed % 80);\n const dlN = 200 + seed % 1800;\n const dl = dlN >= 1000 ? (dlN / 1000).toFixed(1) + 'k' : String(dlN);\n const intro = m.feature || (m.name + ' · ' + m.style + '。' + sourceLabel + ' 模特,可作为商品宣传场景的人物资产。');\n const tags = [m.vibe, m.style, m.age, m.hairLen, m.region].filter(Boolean);\n const props = [\n ['性别', m.gender], ['种族', m.region], ['作品ID', assetId],\n ['年龄段', m.age], ['气质', m.vibe], ['创作人', 'Airshelf'],\n ['身高', m.height], ['体格', m.build], ['文件大小', fileSize],\n ['发型', m.hairLen + ' · ' + m.hairColor], ['来源', sourceLabel], ['发布时间', '2024-05-20'],\n ];\n\n document.getElementById('md-title').textContent = m.name;\n document.getElementById('md-kind').textContent = '/ 人物 · ' + sourceLabel;\n document.getElementById('md-portrait-name').textContent = m.name + ' · ' + m.style;\n\n // 当前主立绘 / 三视图 src 缓存,供 zoom 按钮点击使用\n let _mdCurLeadSrc = null;\n let _mdCurLeadName = '';\n let _mdCurTriLabel = '';\n\n // 主立绘 · 移除上次的 <img>(若有),按当前选中重渲\n const leadImgEl = document.querySelector('#md-modal-bg .md-lead-img');\n function _mdSetLead(p) {\n if (!leadImgEl) return;\n const old = leadImgEl.querySelector('img.md-lead-pic');\n if (old) old.remove();\n if (p && p.url) {\n const img = document.createElement('img');\n img.className = 'md-lead-pic';\n img.src = p.url; img.alt = p.name || m.name;\n leadImgEl.insertBefore(img, leadImgEl.firstChild);\n _mdCurLeadSrc = p.url; _mdCurLeadName = p.name || m.name;\n } else {\n _mdCurLeadSrc = null; _mdCurLeadName = p?.label || m.name;\n }\n }\n\n // 缩略图 strip · 用户上传且有 portraits 数组 → 显示多张;平台预设 → 仅 1 张占位\n const userPortraits = (m.source === 'own' && Array.isArray(m.portraits) && m.portraits.length > 0) ? m.portraits : null;\n const thumbList = userPortraits || [{ label: '立绘' }];\n const thumbsEl = document.getElementById('md-thumbs');\n thumbsEl.innerHTML = thumbList.map((p, i) => {\n const inner = p.url\n ? `<img src=\"${p.url}\" alt=\"${(p.name||'').replace(/\"/g,'"')}\">`\n : `<span class=\"ph-frame\">${p.label || ('v'+(i+1))}</span>`;\n return `<div class=\"thumb${i === 0 ? ' active' : ''}\" data-idx=\"${i}\">${inner}</div>`;\n }).join('');\n _mdSetLead(thumbList[0]);\n thumbsEl.querySelectorAll('.thumb').forEach(t => t.addEventListener('click', () => {\n thumbsEl.querySelectorAll('.thumb').forEach(x => x.classList.remove('active'));\n t.classList.add('active');\n _mdSetLead(thumbList[+t.dataset.idx]);\n }));\n\n // 三视图 · 用户上传且有 triVersions → 保留版本,可切换;否则单张占位\n const userTri = (m.source === 'own' && Array.isArray(m.triVersions) && m.triVersions.length > 0) ? m.triVersions : null;\n const viewsEl = document.querySelector('#md-modal-bg .md-views');\n const _zoomSvg = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5\"/></svg>';\n if (viewsEl) {\n if (userTri) {\n const stripHtml = userTri.map((v, i) => `<div class=\"v-thumb${i === userTri.length - 1 ? ' active' : ''}\" data-idx=\"${i}\"><span class=\"v\">${v.label}</span></div>`).join('');\n const cur = userTri[userTri.length - 1];\n _mdCurTriLabel = cur.label;\n viewsEl.innerHTML = `\n <div class=\"md-view\"><div class=\"lbl\">正 / 侧 / 背 · ${cur.label} · ${cur.ts}</div><button class=\"md-zoom-btn\" type=\"button\" id=\"md-tri-zoom\" aria-label=\"查看大图\" title=\"查看大图\">${_zoomSvg}</button></div>\n <div class=\"md-view-versions\">${stripHtml}</div>`;\n const mainView = viewsEl.querySelector('.md-view .lbl');\n viewsEl.querySelectorAll('.v-thumb').forEach(t => t.addEventListener('click', () => {\n viewsEl.querySelectorAll('.v-thumb').forEach(x => x.classList.remove('active'));\n t.classList.add('active');\n const v = userTri[+t.dataset.idx];\n _mdCurTriLabel = v.label;\n if (mainView) mainView.textContent = `正 / 侧 / 背 · ${v.label} · ${v.ts}`;\n }));\n } else {\n _mdCurTriLabel = '三视图';\n viewsEl.innerHTML = `<div class=\"md-view\"><div class=\"lbl\">正 / 侧 / 背 · 三视图</div><button class=\"md-zoom-btn\" type=\"button\" id=\"md-tri-zoom\" aria-label=\"查看大图\" title=\"查看大图\">${_zoomSvg}</button></div>`;\n }\n const triZoom = viewsEl.querySelector('#md-tri-zoom');\n triZoom?.addEventListener('click', e => {\n e.stopPropagation();\n // 三视图当前仅占位渲染,无真实图 src:用占位 + 名字提示\n if (window.Shell?._openLightbox) Shell._openLightbox('', m.name + ' · 三视图 · ' + _mdCurTriLabel);\n });\n }\n\n // 主立绘 zoom 按钮点击 → 打开 lightbox(无 src 时显示占位名)\n document.getElementById('md-lead-zoom')?.replaceWith(document.getElementById('md-lead-zoom').cloneNode(true));\n document.getElementById('md-lead-zoom')?.addEventListener('click', e => {\n e.stopPropagation();\n if (window.Shell?._openLightbox) Shell._openLightbox(_mdCurLeadSrc || '', _mdCurLeadName || m.name);\n });\n\n // 简介 + 标签\n document.getElementById('md-intro').textContent = intro;\n document.getElementById('md-tags').innerHTML = tags.map(t => `<span class=\"tag-chip\">${t}</span>`).join('') +\n `<button class=\"tag-add\" type=\"button\" title=\"添加标签\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"><path d=\"M8 3v10M3 8h10\"/></svg></button>`;\n\n // 属性表\n document.getElementById('md-props').innerHTML = props.map(([k, v]) => `<div class=\"prop\"><span class=\"k\">${k}</span><span class=\"v\">${v}</span></div>`).join('');\n\n document.getElementById('md-modal-bg').classList.add('show');\n}\ndocument.getElementById('md-close-btn').addEventListener('click', () => {\n document.getElementById('md-modal-bg').classList.remove('show');\n});\ndocument.getElementById('md-select').addEventListener('click', () => {\n let pickedName = '';\n if (_detailModelId) {\n state.selectedModel = _detailModelId;\n // 重建 mini 网格 · 让选中的模特(无论是否预设)显示在首位\n renderModelMini();\n // 同步模特库 modal 网格里的 .selected 类\n document.querySelectorAll('.ml-grid .model-card').forEach(c =>\n c.classList.toggle('selected', c.dataset.id === state.selectedModel)\n );\n updateModelSummary();\n const m = MODELS.find(x => x.id === _detailModelId);\n if (m) pickedName = m.name;\n }\n // 关闭模特详情 + 模特库两个 modal,回到工作台主视图\n document.getElementById('md-modal-bg').classList.remove('show');\n document.getElementById('ml-modal-bg').classList.remove('show');\n // 工作台主区域 scroll 到「选择模特」step,可视化反馈\n document.getElementById('model-grid-mini')?.scrollIntoView({ behavior: 'smooth', block: 'center' });\n if (pickedName && window.Shell?.toast) {\n Shell.toast('已选用模特「' + pickedName + '」', '可继续选择服装与场景');\n }\n});\n\n// pv-swap 也打开模特库\ndocument.getElementById('pv-swap').addEventListener('click', () => {\n document.getElementById('open-model-lib').click();\n});\n\n// URL ?product=商品名 → 替换默认选中(从 products.html 跳过来时携带)\n(function applyUrlProduct() {\n const q = new URLSearchParams(location.search);\n const productName = q.get('product');\n if (!productName) return;\n let p = PRODUCTS.find(x => x.name === productName);\n if (!p) {\n p = { id: 'np-' + Date.now().toString(36), name: productName, cat: '美妆个护', meta: '新建 · 待补充' };\n PRODUCTS.unshift(p);\n }\n state.selectedProd = p.id;\n})();\n\n/* ============================================================\n 生成批次 (localStorage) · 按当前商品过滤 · 立即生成时追加\n ============================================================ */\n(function () {\n 'use strict';\n const TASK_TYPE = 'model';\n const KEY = 'fs-image-tasks-' + TASK_TYPE;\n\n let tasks = [];\n\n function load() {\n try { return JSON.parse(localStorage.getItem(KEY) || '[]'); } catch (e) { return []; }\n }\n function save(arr) {\n try { localStorage.setItem(KEY, JSON.stringify(arr)); } catch (e) {}\n }\n function buildSnapshot() {\n return {\n selectedProd: state.selectedProd,\n selectedModel: state.selectedModel,\n count: state.count,\n ratio: state.ratio,\n };\n }\n function timeNow() {\n const d = new Date();\n return ('0'+(d.getMonth()+1)).slice(-2) + '.' + ('0'+d.getDate()).slice(-2) + ' ' + ('0'+d.getHours()).slice(-2) + ':' + ('0'+d.getMinutes()).slice(-2);\n }\n\n // 暴露给上层 (给 cur-prod header 用)\n window._countBatchesForProd = function (prodId) {\n return tasks.filter(t => t.snap && t.snap.selectedProd === prodId).length;\n };\n\n // 切换商品 → 清空 pv-grid (历史批次只在当前 session 持有)\n // cur-prod header 的\"本商品 N 批\"由 _countBatchesForProd 提供累计数\n window.renderBatchesForCurrentProd = function () {\n const grid = document.getElementById('pv-grid');\n if (!grid) return;\n grid.innerHTML = '';\n _batchSeq = 0;\n showPreviewEmpty();\n };\n\n // 立即生成 → push 新批次 + persist + 刷新视图\n document.getElementById('mp-go-btn').addEventListener('click', () => {\n if (!state.selectedModel || !state.selectedProd) return;\n const snap = buildSnapshot();\n const _prod = PRODUCTS.find(p => p.id === state.selectedProd);\n const _model = MODELS.find(m => m.id === state.selectedModel);\n const _name = ((_prod && _prod.name) || '商品') + ' × ' + ((_model && _model.name) || '模特');\n const task = {\n id: 'task-' + Date.now(),\n type: TASK_TYPE,\n name: _name,\n snap,\n status: 'ok',\n time: timeNow(),\n createdAt: Date.now(),\n };\n tasks.push(task);\n save(tasks);\n // 不重渲整个 grid (保留 hover 重跑/采用的实时态),只更新 cur-prod 计数\n updateCurProdHeader();\n });\n\n /* ---------- 初始化 ---------- */\n tasks = load();\n})();\n\n/* ---------- 商品空间折叠 / 展开 ---------- */\n(function () {\n const layout = document.querySelector('.mp-layout');\n const foldBtn = document.querySelector('.mp-side-top .fold');\n const restoreBtn = document.getElementById('mp-side-restore');\n function setSideCollapsed(collapsed) {\n layout?.classList.toggle('side-collapsed', collapsed);\n if (restoreBtn) restoreBtn.hidden = !collapsed;\n if (foldBtn) foldBtn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');\n try { localStorage.setItem('fs-mp-side-collapsed', collapsed ? '1' : '0'); } catch (e) {}\n }\n foldBtn?.addEventListener('click', () => setSideCollapsed(true));\n restoreBtn?.addEventListener('click', () => setSideCollapsed(false));\n try { setSideCollapsed(localStorage.getItem('fs-mp-side-collapsed') === '1'); } catch (e) {}\n})();\n\n// 初始化\nrenderProdSpace();\nrenderSelectedProds();\nrenderModelMini(); // MODELS 已声明,可安全调用\nupdateModelSummary();\nupdateCost();\nshowPreviewEmpty(); // 默认 → 右侧显示空态\n// 默认选中: URL ?product= 优先, 否则选 PRODUCTS 首位 (= 最近编辑)\nconst defaultProdId = state.selectedProd || (PRODUCTS[0] && PRODUCTS[0].id);\nif (defaultProdId) selectProduct(defaultProdId);\n</script>\n<script src=\"/exact/assets/api-bridge.js?v=2026060114\"></script>\n</body>\n</html>\n",
|
||
"modelPhotoDemoA": "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta name=\"x-airshelf-exact-source\" content=\"model-photo-demo-a.html\">\n<meta charset=\"utf-8\">\n<title>方案 A · 商品空间 · 模特上身图 · Airshelf</title>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"/exact/assets/restraint.css?v=2026060101\">\n<style>\n .app { height: 100vh; overflow: hidden; }\n main { display: flex; flex-direction: column; min-height: 0; }\n #page-content { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0; }\n\n /* ===== 两栏:左侧栏 / 右侧主区 ===== */\n .dma {\n flex: 1; min-height: 0;\n display: grid;\n grid-template-columns: 260px 1fr;\n }\n\n /* ── 左侧栏 · 仅商品空间(搜索 + 列表 + 全部入口) ── */\n .dma-side {\n border-right: 1px solid var(--border-faint);\n background: var(--surface);\n display: flex; flex-direction: column;\n min-height: 0;\n }\n .dma-side-h {\n padding: 14px 14px 10px;\n flex-shrink: 0;\n }\n .dma-side-h .ti-row {\n display: flex; align-items: center;\n margin-bottom: 10px;\n }\n .dma-side-h .ti {\n font-size: 11px; font-family: var(--font-mono);\n color: var(--black-alpha-48); letter-spacing: .08em;\n text-transform: uppercase;\n }\n .dma-side-h .ti-row .add {\n margin-left: auto;\n width: 22px; height: 22px;\n display: grid; place-items: center;\n background: var(--heat-12); color: var(--heat);\n border: 0; border-radius: var(--r-sm);\n cursor: pointer;\n }\n .dma-side-h .add svg { width: 11px; height: 11px; }\n /* 搜索框 (Q1-A) */\n .dma-search {\n display: flex; align-items: center; gap: 8px;\n height: 32px; padding: 0 10px;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n transition: border-color var(--t-base), background var(--t-base);\n }\n .dma-search:focus-within { border-color: var(--heat-40); background: var(--surface); }\n .dma-search svg { width: 13px; height: 13px; color: var(--black-alpha-48); flex-shrink: 0; }\n .dma-search input {\n flex: 1; min-width: 0; height: 100%;\n border: 0; outline: 0; background: transparent;\n font-size: 12.5px; color: var(--accent-black); font-family: inherit;\n }\n\n /* 商品空间列表(flex:1 占据中部所有剩余空间) */\n .dma-prod-list {\n flex: 1; min-height: 0;\n overflow-y: auto;\n padding: 4px 10px 10px;\n display: flex; flex-direction: column; gap: 4px;\n }\n .dma-prod {\n display: flex; align-items: center; gap: 10px;\n padding: 8px;\n border: 1px solid transparent;\n border-radius: var(--r-sm);\n cursor: pointer;\n transition: background var(--t-base), border-color var(--t-base);\n }\n .dma-prod:hover { background: var(--background-lighter); }\n .dma-prod.active { background: var(--heat-12); border-color: var(--heat-20); }\n .dma-prod .thumb {\n flex-shrink: 0;\n width: 40px; height: 40px;\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n overflow: hidden;\n background: repeating-linear-gradient(135deg, transparent 0 4px, rgba(0,0,0,.04) 4px 5px);\n display: grid; place-items: center;\n font-family: var(--font-mono); font-size: 9px;\n color: var(--black-alpha-32);\n }\n .dma-prod.active .thumb { border-color: var(--heat); }\n .dma-prod .body { flex: 1; min-width: 0; }\n .dma-prod .nm {\n font-size: 12.5px;\n color: var(--accent-black); font-weight: 500;\n line-height: 1.3;\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n }\n .dma-prod.active .nm { color: var(--heat); font-weight: 600; }\n .dma-prod .sub {\n margin-top: 2px;\n font-family: var(--font-mono); font-size: 10px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n }\n\n /* 全部商品入口(Q1-B · 固定贴底) */\n .dma-all {\n flex-shrink: 0;\n margin: 0 10px 12px;\n padding: 10px 12px;\n background: var(--background-lighter);\n border: 1px dashed var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--black-alpha-72);\n font-size: 12px;\n font-family: inherit;\n cursor: pointer;\n display: flex; align-items: center; gap: 6px;\n transition: border-color var(--t-base), color var(--t-base), background var(--t-base);\n }\n .dma-all:hover { border-color: var(--heat); color: var(--heat); background: var(--heat-12); }\n .dma-all .ct {\n color: var(--black-alpha-48);\n font-family: var(--font-mono); font-size: 10.5px;\n margin-left: auto;\n }\n .dma-all svg { width: 12px; height: 12px; }\n\n /* ── 右侧主区 ── */\n .dma-main {\n display: flex; flex-direction: column;\n min-height: 0;\n overflow: hidden;\n }\n .dma-main-h {\n flex-shrink: 0;\n display: flex; align-items: center; gap: 14px;\n padding: 14px 28px;\n border-bottom: 1px solid var(--border-faint);\n background: var(--surface);\n }\n .dma-main-h .crumb {\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48); letter-spacing: .04em;\n }\n .dma-main-h h2 {\n font-size: 20px; font-weight: 600;\n letter-spacing: -.015em;\n color: var(--accent-black);\n }\n .dma-main-h .stats {\n margin-left: auto;\n display: flex; gap: 6px;\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n }\n .dma-main-h .stats b { color: var(--accent-black); font-weight: 600; }\n .dma-main-h .stats .sep { color: var(--black-alpha-24); }\n\n /* 主区:参数 + 结果横向二栏 */\n .dma-body {\n flex: 1; min-height: 0;\n display: grid;\n grid-template-columns: 320px 1fr;\n gap: 0;\n }\n\n /* 左侧参数面板 */\n .dma-form {\n border-right: 1px solid var(--border-faint);\n background: var(--surface);\n display: flex; flex-direction: column;\n min-height: 0;\n }\n .dma-form-scroll {\n flex: 1; min-height: 0; overflow-y: auto;\n padding: 16px 18px;\n }\n .dma-field { margin-bottom: 16px; }\n .dma-field-h {\n font-size: 12px; font-weight: 600;\n color: var(--accent-black);\n margin-bottom: 8px;\n }\n .dma-field-h .opt {\n font-weight: 400; font-size: 11px;\n color: var(--black-alpha-48); margin-left: 4px;\n }\n .dma-models {\n display: grid; grid-template-columns: repeat(3, 1fr);\n gap: 8px;\n }\n .dma-model {\n aspect-ratio: 3 / 4;\n border: 1px solid var(--border-faint);\n background: var(--background-lighter);\n border-radius: var(--r-sm);\n position: relative; cursor: pointer;\n overflow: hidden;\n transition: border-color var(--t-base);\n }\n .dma-model:hover { border-color: var(--black-alpha-32); }\n .dma-model.selected { border-color: var(--heat); border-width: 2px; }\n .dma-model .ph {\n position: absolute; inset: 0;\n display: grid; place-items: center;\n font-family: var(--font-mono); font-size: 10px;\n color: var(--black-alpha-32);\n background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);\n }\n .dma-model .nm {\n position: absolute; bottom: 0; left: 0; right: 0;\n padding: 4px 6px;\n background: linear-gradient(transparent, rgba(0,0,0,.5));\n font-size: 10px; color: #fff; font-weight: 500;\n }\n .dma-model.selected::after {\n content: ''; position: absolute; top: 4px; right: 4px;\n width: 14px; height: 14px;\n background: var(--heat) url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E\") no-repeat center / 9px;\n border-radius: 50%;\n }\n\n .dma-chip-row { display: flex; flex-wrap: wrap; gap: 6px; }\n .dma-chip {\n display: inline-flex; align-items: center;\n height: 28px; padding: 0 11px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n font-size: 12px; color: var(--black-alpha-72);\n font-family: inherit; cursor: pointer;\n transition: border-color var(--t-base), color var(--t-base), background var(--t-base);\n }\n .dma-chip:hover { border-color: var(--black-alpha-32); color: var(--accent-black); }\n .dma-chip.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); font-weight: 600; }\n\n .dma-form-cta {\n flex-shrink: 0;\n padding: 14px 18px;\n border-top: 1px solid var(--border-faint);\n background: var(--surface);\n }\n .dma-cost {\n display: flex; align-items: center; justify-content: space-between;\n margin-bottom: 10px;\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n }\n .dma-cost .v { color: var(--accent-black); font-weight: 600; }\n .dma-gen {\n width: 100%; height: 42px;\n background: var(--heat); color: #fff;\n border: 1px solid var(--heat); border-radius: var(--r-md);\n font-size: 14px; font-weight: 600; font-family: inherit;\n cursor: pointer;\n display: inline-flex; align-items: center; justify-content: center; gap: 8px;\n box-shadow: var(--shadow-cta);\n }\n .dma-gen svg { width: 15px; height: 15px; }\n\n /* 右侧结果区 */\n .dma-result {\n background: var(--background-base);\n min-height: 0; overflow-y: auto;\n padding: 22px 24px;\n }\n .dma-result-h {\n display: flex; align-items: baseline; gap: 10px;\n margin-bottom: 14px;\n }\n .dma-result-h .ti { font-size: 15px; font-weight: 600; color: var(--accent-black); }\n .dma-result-h .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }\n\n /* 批次卡 */\n .dma-batch {\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n padding: 14px 16px;\n margin-bottom: 14px;\n }\n .dma-batch-h {\n display: flex; align-items: center; gap: 10px;\n margin-bottom: 12px;\n padding-bottom: 10px;\n border-bottom: 1px solid var(--border-faint);\n }\n .dma-batch-h .pic {\n width: 28px; height: 28px;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n display: grid; place-items: center;\n color: var(--heat);\n font-family: var(--font-mono); font-size: 11px; font-weight: 600;\n }\n .dma-batch-h .meta { flex: 1; min-width: 0; }\n .dma-batch-h .nm { font-size: 13px; font-weight: 600; color: var(--accent-black); }\n .dma-batch-h .info { margin-top: 2px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .dma-batch-h .info .sep { color: var(--black-alpha-24); }\n .dma-batch-h .ops { display: flex; gap: 4px; }\n .dma-batch-h .ops button {\n width: 28px; height: 28px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--black-alpha-56); cursor: pointer;\n display: grid; place-items: center;\n }\n .dma-batch-h .ops button:hover { border-color: var(--heat-20); color: var(--heat); }\n .dma-batch-h .ops button svg { width: 13px; height: 13px; }\n\n .dma-batch-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }\n @media (max-width: 1400px) { .dma-batch-grid { grid-template-columns: repeat(3, 1fr); } }\n .dma-cell {\n aspect-ratio: 3 / 4;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n overflow: hidden; position: relative;\n cursor: pointer;\n }\n .dma-cell .ph {\n position: absolute; inset: 0;\n display: grid; place-items: center;\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-32);\n background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);\n }\n .dma-cell .tag {\n position: absolute; top: 6px; left: 6px;\n padding: 2px 6px;\n background: rgba(0,0,0,.65);\n color: #fff;\n border-radius: var(--r-sm);\n font-size: 10px; font-weight: 500;\n backdrop-filter: blur(4px);\n }\n\n /* 空态 */\n .dma-empty {\n height: 100%;\n display: flex; flex-direction: column; align-items: center; justify-content: center;\n gap: 12px;\n color: var(--black-alpha-48);\n text-align: center;\n }\n .dma-empty .ic {\n width: 56px; height: 56px;\n background: var(--surface); border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n display: grid; place-items: center;\n color: var(--heat);\n }\n .dma-empty .ic svg { width: 22px; height: 22px; }\n .dma-empty .mono { font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em; }\n\n /* 顶部\"提示条\" · 这是 demo */\n .dma-banner {\n margin: 12px 28px 0;\n padding: 8px 12px;\n background: var(--heat-12);\n border: 1px dashed var(--heat-20);\n border-radius: var(--r-sm);\n font-size: 12px;\n color: var(--accent-black);\n font-family: var(--font-mono); letter-spacing: .02em;\n }\n .dma-banner b { color: var(--heat); }\n</style>\n</head>\n<body>\n<div id=\"page\">\n\n <div class=\"dma-banner\">// DEMO · 方案 A · <b>商品 = 项目空间</b>(Q1: A+B)。左栏仅商品空间:🔍 搜索 + 最近 6 条 + <b>全部商品</b>兜底入口;历史任务已挪进主区。主区:模特卡 + 张数 + 比例 + 立即生成,生成结果自动绑定到当前商品。</div>\n\n <div class=\"dma\">\n\n <!-- ===== 左侧栏 · 仅商品空间 (搜索 + 列表 + 全部入口) ===== -->\n <aside class=\"dma-side\">\n <div class=\"dma-side-h\">\n <div class=\"ti-row\">\n <span class=\"ti\">商品空间</span>\n <button class=\"add\" type=\"button\" title=\"新建商品\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 5v14M5 12h14\"/></svg>\n </button>\n </div>\n <!-- Q1-A · 搜索框 -->\n <div class=\"dma-search\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/></svg>\n <input type=\"text\" placeholder=\"搜索商品 / 分类\">\n </div>\n </div>\n\n <!-- 商品列表 · 最近 6 条 -->\n <div class=\"dma-prod-list\">\n <div class=\"dma-prod active\">\n <div class=\"thumb\">主图</div>\n <div class=\"body\">\n <div class=\"nm\">透真补水面膜</div>\n <div class=\"sub\">// 美妆个护 · 6 批</div>\n </div>\n </div>\n <div class=\"dma-prod\">\n <div class=\"thumb\">主图</div>\n <div class=\"body\">\n <div class=\"nm\">透真清透防晒霜</div>\n <div class=\"sub\">// 美妆个护 · 3 批</div>\n </div>\n </div>\n <div class=\"dma-prod\">\n <div class=\"thumb\">主图</div>\n <div class=\"body\">\n <div class=\"nm\">南卡 Lite Pro 蓝牙耳机</div>\n <div class=\"sub\">// 数码 3C · 2 批</div>\n </div>\n </div>\n <div class=\"dma-prod\">\n <div class=\"thumb\">主图</div>\n <div class=\"body\">\n <div class=\"nm\">滋啦速食牛肉面</div>\n <div class=\"sub\">// 食品饮料 · 1 批</div>\n </div>\n </div>\n <div class=\"dma-prod\">\n <div class=\"thumb\">主图</div>\n <div class=\"body\">\n <div class=\"nm\">三顿半同款冻干咖啡</div>\n <div class=\"sub\">// 食品饮料 · 1 批</div>\n </div>\n </div>\n <div class=\"dma-prod\">\n <div class=\"thumb\">主图</div>\n <div class=\"body\">\n <div class=\"nm\">小熊 4L 可视空气炸锅</div>\n <div class=\"sub\">// 家居家电 · 0 批</div>\n </div>\n </div>\n </div>\n\n <!-- Q1-B · 全部商品入口(贴底) -->\n <button class=\"dma-all\" type=\"button\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"2\"/><path d=\"M3 9h18M9 4v16\"/></svg>\n 全部商品\n <span class=\"ct\">24 个</span>\n <svg style=\"margin-left:4px\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12h14M12 5l7 7-7 7\"/></svg>\n </button>\n </aside>\n\n <!-- ===== 右侧主区 ===== -->\n <section class=\"dma-main\">\n\n <!-- 顶部 · 商品名 + 统计 -->\n <div class=\"dma-main-h\">\n <div>\n <div class=\"crumb\">// 商品空间</div>\n <h2>透真补水面膜</h2>\n </div>\n <div class=\"stats\">\n <span>本商品 <b>6</b> 批</span>\n <span class=\"sep\">·</span>\n <span>累计 <b>22</b> 张图</span>\n <span class=\"sep\">·</span>\n <span>最近 <b>3 分钟前</b></span>\n </div>\n </div>\n\n <!-- 中间:参数面板 + 结果 -->\n <div class=\"dma-body\">\n\n <!-- 参数面板(单一职责:挑模特 + 张数) -->\n <div class=\"dma-form\">\n <div class=\"dma-form-scroll\">\n\n <div class=\"dma-field\">\n <div class=\"dma-field-h\">选择模特<span class=\"opt\">(已锁定商品 · 透真补水面膜)</span></div>\n <div class=\"dma-models\">\n <div class=\"dma-model selected\">\n <div class=\"ph\">Ava · 3:4</div>\n <div class=\"nm\">Ava</div>\n </div>\n <div class=\"dma-model\">\n <div class=\"ph\">Zoe · 3:4</div>\n <div class=\"nm\">Zoe</div>\n </div>\n <div class=\"dma-model\">\n <div class=\"ph\">Ben · 3:4</div>\n <div class=\"nm\">Ben</div>\n </div>\n <div class=\"dma-model\">\n <div class=\"ph\">Lin · 3:4</div>\n <div class=\"nm\">Lin</div>\n </div>\n <div class=\"dma-model\">\n <div class=\"ph\">Mia · 3:4</div>\n <div class=\"nm\">Mia</div>\n </div>\n <div class=\"dma-model\" style=\"border-style:dashed;display:flex;align-items:center;justify-content:center;color:var(--black-alpha-48);\">\n <span style=\"font-size:18px\">+</span>\n </div>\n </div>\n </div>\n\n <div class=\"dma-field\">\n <div class=\"dma-field-h\">生成张数</div>\n <div class=\"dma-chip-row\">\n <button class=\"dma-chip\" type=\"button\">1 张</button>\n <button class=\"dma-chip\" type=\"button\">2 张</button>\n <button class=\"dma-chip active\" type=\"button\">4 张</button>\n <button class=\"dma-chip\" type=\"button\">8 张</button>\n </div>\n </div>\n\n <div class=\"dma-field\">\n <div class=\"dma-field-h\">画面比例</div>\n <div class=\"dma-chip-row\">\n <button class=\"dma-chip\" type=\"button\">1:1</button>\n <button class=\"dma-chip active\" type=\"button\">3:4</button>\n <button class=\"dma-chip\" type=\"button\">9:16</button>\n <button class=\"dma-chip\" type=\"button\">16:9</button>\n </div>\n </div>\n\n <div class=\"dma-field\">\n <div class=\"dma-field-h\">补充提示词<span class=\"opt\">(选填)</span></div>\n <textarea style=\"width:100%;min-height:60px;padding:8px 10px;background:var(--background-lighter);border:1px solid var(--black-alpha-12);border-radius:var(--r-sm);font-family:inherit;font-size:12.5px;outline:none;resize:vertical\" placeholder=\"例:户外阳光、敷面膜的特写、白底产品摄影\"></textarea>\n </div>\n\n </div>\n\n <div class=\"dma-form-cta\">\n <div class=\"dma-cost\">\n <span>预估扣费 <span class=\"v\">≈ ¥1.20</span></span>\n <span>余额 ¥327.40</span>\n </div>\n <button class=\"dma-gen\" type=\"button\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z\"/></svg>\n 立即生成 · 透真补水面膜 × Ava\n </button>\n </div>\n </div>\n\n <!-- 右侧结果 · 当前商品全部批次 -->\n <div class=\"dma-result\">\n <div class=\"dma-result-h\">\n <span class=\"ti\">最近批次 · Ava × 4 张</span>\n <span class=\"sub\">// 3 分钟前 · 已完成</span>\n </div>\n\n <!-- 批次 1 -->\n <div class=\"dma-batch\">\n <div class=\"dma-batch-h\">\n <div class=\"pic\">4×</div>\n <div class=\"meta\">\n <div class=\"nm\">Ava × 4 张</div>\n <div class=\"info\">透真补水面膜 <span class=\"sep\">·</span> 3:4 <span class=\"sep\">·</span> 3 分钟前 <span class=\"sep\">·</span> ¥1.20</div>\n </div>\n <div class=\"ops\">\n <button type=\"button\" title=\"全部重跑\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5\"/></svg></button>\n <button type=\"button\" title=\"全部下载\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3\"/></svg></button>\n <button type=\"button\" title=\"加入资产库\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z\"/></svg></button>\n </div>\n </div>\n <div class=\"dma-batch-grid\">\n <div class=\"dma-cell\"><div class=\"ph\">Ava · #1</div><span class=\"tag\">3:4</span></div>\n <div class=\"dma-cell\"><div class=\"ph\">Ava · #2</div><span class=\"tag\">3:4</span></div>\n <div class=\"dma-cell\"><div class=\"ph\">Ava · #3</div><span class=\"tag\">3:4</span></div>\n <div class=\"dma-cell\"><div class=\"ph\">Ava · #4</div><span class=\"tag\">3:4</span></div>\n </div>\n </div>\n\n <!-- 批次 2 -->\n <div class=\"dma-batch\">\n <div class=\"dma-batch-h\">\n <div class=\"pic\">4×</div>\n <div class=\"meta\">\n <div class=\"nm\">Zoe × 4 张</div>\n <div class=\"info\">透真补水面膜 <span class=\"sep\">·</span> 3:4 <span class=\"sep\">·</span> 12 分钟前 <span class=\"sep\">·</span> ¥1.20</div>\n </div>\n <div class=\"ops\">\n <button type=\"button\" title=\"全部重跑\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5\"/></svg></button>\n <button type=\"button\" title=\"全部下载\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3\"/></svg></button>\n <button type=\"button\" title=\"加入资产库\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z\"/></svg></button>\n </div>\n </div>\n <div class=\"dma-batch-grid\">\n <div class=\"dma-cell\"><div class=\"ph\">Zoe · #1</div><span class=\"tag\">3:4</span></div>\n <div class=\"dma-cell\"><div class=\"ph\">Zoe · #2</div><span class=\"tag\">3:4</span></div>\n <div class=\"dma-cell\"><div class=\"ph\">Zoe · #3</div><span class=\"tag\">3:4</span></div>\n <div class=\"dma-cell\"><div class=\"ph\">Zoe · #4</div><span class=\"tag\">3:4</span></div>\n </div>\n </div>\n\n <!-- 批次 3 -->\n <div class=\"dma-batch\">\n <div class=\"dma-batch-h\">\n <div class=\"pic\">2×</div>\n <div class=\"meta\">\n <div class=\"nm\">Ben × 2 张</div>\n <div class=\"info\">透真补水面膜 <span class=\"sep\">·</span> 3:4 <span class=\"sep\">·</span> 刚刚 <span class=\"sep\">·</span> 生成中</div>\n </div>\n <div class=\"ops\">\n <button type=\"button\" title=\"取消\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6 6l12 12M6 18L18 6\"/></svg></button>\n </div>\n </div>\n <div class=\"dma-batch-grid\">\n <div class=\"dma-cell\"><div class=\"ph\">生成中…</div></div>\n <div class=\"dma-cell\"><div class=\"ph\">生成中…</div></div>\n </div>\n </div>\n\n </div>\n </div>\n\n </section>\n\n </div>\n\n</div>\n\n<script src=\"/exact/assets/icons.js?v=2026052608\"></script>\n<script src=\"/exact/assets/shell.js?v=2026060101\"></script>\n<script>\nShell.render({\n active: 'asset-factory',\n crumbs: [\n { label: '工作台', href: 'index.html' },\n { label: '图片生成', href: 'asset-factory.html' },\n { label: '模特上身图 · 方案 A · Demo' }\n ]\n});\n\n// 简单交互:点击商品 / 任务切换激活态\ndocument.querySelectorAll('.dma-prod').forEach(el => {\n el.addEventListener('click', () => {\n document.querySelectorAll('.dma-prod').forEach(x => x.classList.remove('active'));\n el.classList.add('active');\n });\n});\ndocument.querySelectorAll('.dma-task').forEach(el => {\n el.addEventListener('click', () => {\n document.querySelectorAll('.dma-task').forEach(x => x.classList.remove('active'));\n el.classList.add('active');\n });\n});\ndocument.querySelectorAll('.dma-model').forEach(el => {\n el.addEventListener('click', () => {\n document.querySelectorAll('.dma-model').forEach(x => x.classList.remove('selected'));\n el.classList.add('selected');\n });\n});\ndocument.querySelectorAll('.dma-chip').forEach(el => {\n el.addEventListener('click', () => {\n // 同组互斥\n el.parentElement.querySelectorAll('.dma-chip').forEach(x => x.classList.remove('active'));\n el.classList.add('active');\n });\n});\n</script>\n<script src=\"/exact/assets/api-bridge.js?v=2026060114\"></script>\n</body>\n</html>\n",
|
||
"modelPhotoDemoB": "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta name=\"x-airshelf-exact-source\" content=\"model-photo-demo-b.html\">\n<meta charset=\"utf-8\">\n<title>方案 A · v2 · 模特上身图 · Airshelf</title>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"/exact/assets/restraint.css?v=2026060101\">\n<style>\n .app { height: 100vh; overflow: hidden; }\n main { display: flex; flex-direction: column; min-height: 0; }\n #page-content { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0; }\n\n /* ===== 两栏:左栏(纯商品空间) + 主区 ===== */\n .dmb {\n flex: 1; min-height: 0;\n display: grid;\n grid-template-columns: 260px 1fr;\n }\n\n /* ── 左栏 · 商品空间 ── */\n .dmb-side {\n border-right: 1px solid var(--border-faint);\n background: var(--surface);\n display: flex; flex-direction: column;\n min-height: 0;\n }\n .dmb-side-h {\n padding: 14px 16px 10px;\n flex-shrink: 0;\n }\n .dmb-side-h .ti {\n font-size: 11px; font-family: var(--font-mono);\n color: var(--black-alpha-48); letter-spacing: .08em;\n text-transform: uppercase;\n margin-bottom: 10px;\n }\n /* 搜索框 */\n .dmb-search {\n display: flex; align-items: center; gap: 8px;\n height: 32px; padding: 0 10px;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n transition: border-color var(--t-base);\n }\n .dmb-search:focus-within { border-color: var(--heat-40); background: var(--surface); }\n .dmb-search svg { width: 13px; height: 13px; color: var(--black-alpha-48); flex-shrink: 0; }\n .dmb-search input {\n flex: 1; min-width: 0; height: 100%;\n border: 0; outline: 0; background: transparent;\n font-size: 12.5px; color: var(--accent-black); font-family: inherit;\n }\n\n /* 商品列表 */\n .dmb-list {\n flex: 1; min-height: 0;\n overflow-y: auto;\n padding: 8px 10px 8px;\n display: flex; flex-direction: column; gap: 6px;\n }\n .dmb-prod {\n display: flex; align-items: center; gap: 10px;\n padding: 8px;\n border: 1px solid transparent;\n border-radius: var(--r-sm);\n cursor: pointer;\n transition: background var(--t-base), border-color var(--t-base);\n }\n .dmb-prod:hover { background: var(--background-lighter); }\n .dmb-prod.active { background: var(--heat-12); border-color: var(--heat-20); }\n .dmb-prod .thumb {\n flex-shrink: 0;\n width: 44px; height: 44px;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n overflow: hidden; position: relative;\n display: grid; place-items: center;\n font-family: var(--font-mono); font-size: 9px;\n color: var(--black-alpha-32);\n background: repeating-linear-gradient(135deg, transparent 0 4px, rgba(0,0,0,.04) 4px 5px);\n }\n .dmb-prod.active .thumb { border-color: var(--heat); }\n .dmb-prod .body { flex: 1; min-width: 0; }\n .dmb-prod .nm {\n font-size: 12.5px;\n color: var(--accent-black); font-weight: 500;\n line-height: 1.3;\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n }\n .dmb-prod.active .nm { color: var(--heat); font-weight: 600; }\n .dmb-prod .sub {\n margin-top: 2px;\n font-family: var(--font-mono); font-size: 10px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n }\n\n /* 底部 · 全部商品入口 */\n .dmb-all {\n flex-shrink: 0;\n margin: 0 10px 12px;\n padding: 10px 12px;\n background: var(--background-lighter);\n border: 1px dashed var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--black-alpha-72);\n font-size: 12px;\n cursor: pointer;\n display: flex; align-items: center; gap: 6px;\n transition: border-color var(--t-base), color var(--t-base), background var(--t-base);\n }\n .dmb-all:hover { border-color: var(--heat); color: var(--heat); background: var(--heat-12); }\n .dmb-all .ct { color: var(--black-alpha-48); font-family: var(--font-mono); font-size: 10.5px; margin-left: auto; }\n .dmb-all svg { width: 12px; height: 12px; }\n\n /* ── 主区 ── */\n .dmb-main {\n display: flex; flex-direction: column;\n min-height: 0; overflow: hidden;\n position: relative;\n }\n .dmb-main-h {\n flex-shrink: 0;\n padding: 16px 28px 12px;\n border-bottom: 1px solid var(--border-faint);\n background: var(--surface);\n }\n .dmb-main-h .crumb {\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48); letter-spacing: .04em;\n margin-bottom: 4px;\n }\n .dmb-main-h h2 {\n font-size: 22px; font-weight: 600;\n letter-spacing: -.015em;\n color: var(--accent-black);\n }\n .dmb-main-h .row {\n display: flex; align-items: center; gap: 16px;\n margin-top: 6px;\n }\n .dmb-main-h .stats {\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n display: flex; gap: 4px;\n }\n .dmb-main-h .stats b { color: var(--accent-black); font-weight: 600; }\n .dmb-main-h .stats .sep { color: var(--black-alpha-24); }\n .dmb-main-h .spacer { flex: 1; }\n\n /* 主区 toolbar (筛选条 · 仿 image-optimize 顶部) */\n .dmb-main-tb { display: flex; gap: 8px; align-items: center; }\n .dmb-main-tb .icbtn {\n width: 30px; height: 30px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--black-alpha-72);\n cursor: pointer; display: grid; place-items: center;\n }\n .dmb-main-tb .icbtn:hover { border-color: var(--heat-20); color: var(--heat); }\n .dmb-main-tb .icbtn svg { width: 13px; height: 13px; }\n .dmb-main-tb .chip {\n display: inline-flex; align-items: center; gap: 6px;\n height: 30px; padding: 0 10px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n font-size: 12px; color: var(--black-alpha-72);\n font-family: inherit; cursor: pointer;\n }\n .dmb-main-tb .chip:hover { border-color: var(--heat-20); color: var(--heat); }\n .dmb-main-tb .chip svg { width: 10px; height: 10px; opacity: .6; }\n\n /* 主区滚动体 · 任务流 */\n .dmb-stream {\n flex: 1; min-height: 0;\n overflow-y: auto;\n padding: 22px 28px 200px; /* 底部留出参数面板高度 */\n background: var(--background-base);\n }\n .dmb-day-h {\n display: flex; align-items: baseline; gap: 8px;\n margin: 6px 0 10px;\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48); letter-spacing: .06em;\n text-transform: uppercase;\n }\n .dmb-day-h::before {\n content: ''; width: 14px; height: 1px;\n background: var(--black-alpha-24);\n display: inline-block; margin-right: 2px;\n }\n .dmb-day-h .ct {\n color: var(--black-alpha-72); font-weight: 500;\n margin-left: auto;\n text-transform: none; letter-spacing: 0;\n }\n\n /* 批次卡 */\n .dmb-batch {\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n padding: 14px 16px;\n margin-bottom: 14px;\n }\n .dmb-batch-h {\n display: flex; align-items: center; gap: 10px;\n margin-bottom: 12px;\n }\n .dmb-batch-h .pic {\n flex-shrink: 0;\n width: 30px; height: 30px;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n display: grid; place-items: center;\n color: var(--heat);\n font-family: var(--font-mono); font-size: 11px; font-weight: 600;\n }\n .dmb-batch-h .meta { flex: 1; min-width: 0; }\n .dmb-batch-h .nm { font-size: 13.5px; font-weight: 600; color: var(--accent-black); }\n .dmb-batch-h .info {\n margin-top: 2px;\n display: flex; flex-wrap: wrap; gap: 4px;\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n }\n .dmb-batch-h .info .sep { color: var(--black-alpha-24); }\n .dmb-batch-h .ops { display: flex; gap: 4px; }\n .dmb-batch-h .ops button {\n width: 28px; height: 28px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--black-alpha-56); cursor: pointer;\n display: grid; place-items: center;\n transition: border-color var(--t-base), color var(--t-base);\n }\n .dmb-batch-h .ops button:hover { border-color: var(--heat-20); color: var(--heat); }\n .dmb-batch-h .ops button svg { width: 13px; height: 13px; }\n\n /* 状态 pill 紧贴标题右侧 */\n .dmb-batch-h .stat-pill {\n margin-left: 8px;\n padding: 2px 7px;\n font-size: 10px;\n }\n .dmb-batch-h .stat-pill .dot { width: 4px; height: 4px; }\n\n .dmb-batch-grid {\n display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;\n }\n @media (max-width: 1400px) { .dmb-batch-grid { grid-template-columns: repeat(3, 1fr); } }\n .dmb-cell {\n aspect-ratio: 3 / 4;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n overflow: hidden; position: relative;\n cursor: pointer;\n }\n .dmb-cell .ph {\n position: absolute; inset: 0;\n display: grid; place-items: center;\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-32);\n background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);\n }\n .dmb-cell.gen .ph { animation: dmb-pulse 1.4s ease-in-out infinite; }\n @keyframes dmb-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .55; } }\n .dmb-cell.err { border-color: var(--accent-crimson, #c43d3d); }\n .dmb-cell.err .ph { color: var(--accent-crimson, #c43d3d); background: rgba(196,61,61,.05); }\n .dmb-cell .tag {\n position: absolute; top: 6px; left: 6px;\n padding: 2px 6px;\n background: rgba(0,0,0,.65); color: #fff;\n border-radius: var(--r-sm);\n font-size: 10px; font-weight: 500; backdrop-filter: blur(4px);\n }\n\n /* ── 底部 fixed 参数面板 ── */\n .dmb-param-wrap {\n position: absolute; left: 0; right: 0; bottom: 0;\n padding: 14px 28px 22px;\n background: linear-gradient(to bottom, transparent 0, var(--background-base) 24px);\n z-index: 5;\n }\n .dmb-param {\n max-width: 1180px; margin: 0 auto;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: 14px;\n padding: 10px 14px;\n display: flex; align-items: center; gap: 10px;\n box-shadow: 0 6px 24px rgba(0,0,0,.06);\n }\n .dmb-param .label-mono {\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48); letter-spacing: .04em;\n flex-shrink: 0;\n }\n .dmb-param .pchip {\n display: inline-flex; align-items: center; gap: 6px;\n height: 30px; padding: 0 12px;\n background: var(--background-lighter);\n border: 1px solid transparent;\n border-radius: var(--r-pill);\n font-size: 12px; color: var(--black-alpha-72);\n cursor: pointer; font-family: inherit;\n transition: border-color var(--t-base), background var(--t-base), color var(--t-base);\n }\n .dmb-param .pchip:hover { background: var(--surface); border-color: var(--border-faint); }\n .dmb-param .pchip.active { background: var(--heat-12); color: var(--heat); }\n .dmb-param .pchip svg { width: 10px; height: 10px; opacity: .6; }\n .dmb-param .pchip .lbl-mono {\n font-family: var(--font-mono); font-size: 10px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n }\n .dmb-param .pchip.active .lbl-mono { color: var(--heat); }\n .dmb-param .spacer { flex: 1; }\n .dmb-param .meta-right {\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n margin-right: 6px;\n text-align: right;\n }\n .dmb-param .meta-right .v { color: var(--accent-black); font-weight: 600; }\n .dmb-param .gen-btn {\n height: 38px; padding: 0 20px;\n background: var(--heat); color: #fff;\n border: 0; border-radius: var(--r-md);\n font-size: 13.5px; font-weight: 600; font-family: inherit;\n cursor: pointer;\n display: inline-flex; align-items: center; gap: 8px;\n box-shadow: var(--shadow-cta);\n }\n .dmb-param .gen-btn svg { width: 14px; height: 14px; }\n\n /* 顶部 demo 提示条 */\n .dmb-banner {\n margin: 12px 28px 0;\n padding: 8px 12px;\n background: var(--heat-12);\n border: 1px dashed var(--heat-20);\n border-radius: var(--r-sm);\n font-size: 12px; color: var(--accent-black);\n font-family: var(--font-mono); letter-spacing: .02em;\n }\n .dmb-banner b { color: var(--heat); }\n</style>\n</head>\n<body>\n<div id=\"page\">\n\n <div class=\"dmb-banner\">// DEMO v2 · 方案 A · <b>商品空间(A+B) + 任务流主区</b>。左栏只保留商品空间(搜索+最近6条+全部入口),任务列表搬到主区,筛选放主区顶部 toolbar,参数面板底部 fixed 化(类 image-optimize)。</div>\n\n <div class=\"dmb\">\n\n <!-- ===== 左栏 · 商品空间 ===== -->\n <aside class=\"dmb-side\">\n <div class=\"dmb-side-h\">\n <div class=\"ti\">商品空间</div>\n <div class=\"dmb-search\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/></svg>\n <input type=\"text\" placeholder=\"搜索商品 / 分类\">\n </div>\n </div>\n\n <div class=\"dmb-list\">\n <div class=\"dmb-prod active\">\n <div class=\"thumb\">主图</div>\n <div class=\"body\">\n <div class=\"nm\">透真补水面膜</div>\n <div class=\"sub\">美妆个护 · 6 批</div>\n </div>\n </div>\n <div class=\"dmb-prod\">\n <div class=\"thumb\">主图</div>\n <div class=\"body\">\n <div class=\"nm\">透真清透防晒霜</div>\n <div class=\"sub\">美妆个护 · 3 批</div>\n </div>\n </div>\n <div class=\"dmb-prod\">\n <div class=\"thumb\">主图</div>\n <div class=\"body\">\n <div class=\"nm\">南卡 Lite Pro 蓝牙耳机</div>\n <div class=\"sub\">数码 3C · 2 批</div>\n </div>\n </div>\n <div class=\"dmb-prod\">\n <div class=\"thumb\">主图</div>\n <div class=\"body\">\n <div class=\"nm\">滋啦速食牛肉面</div>\n <div class=\"sub\">食品饮料 · 1 批</div>\n </div>\n </div>\n <div class=\"dmb-prod\">\n <div class=\"thumb\">主图</div>\n <div class=\"body\">\n <div class=\"nm\">三顿半同款冻干咖啡</div>\n <div class=\"sub\">食品饮料 · 1 批</div>\n </div>\n </div>\n <div class=\"dmb-prod\">\n <div class=\"thumb\">主图</div>\n <div class=\"body\">\n <div class=\"nm\">小熊 4L 可视空气炸锅</div>\n <div class=\"sub\">家居家电 · 0 批</div>\n </div>\n </div>\n </div>\n\n <button class=\"dmb-all\" type=\"button\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"2\"/><path d=\"M3 9h18M9 4v16\"/></svg>\n 全部商品\n <span class=\"ct\">24 个</span>\n <svg style=\"margin-left:4px\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12h14M12 5l7 7-7 7\"/></svg>\n </button>\n </aside>\n\n <!-- ===== 主区 ===== -->\n <section class=\"dmb-main\">\n\n <!-- 顶部 标题 + stats + toolbar -->\n <div class=\"dmb-main-h\">\n <div class=\"crumb\">// 商品空间 · 模特上身图</div>\n <h2>透真补水面膜</h2>\n <div class=\"row\">\n <div class=\"stats\">\n <span>美妆个护</span><span class=\"sep\">·</span>\n <span>本商品 <b>6</b> 批</span><span class=\"sep\">·</span>\n <span>累计 <b>22</b> 张图</span><span class=\"sep\">·</span>\n <span>最近 <b>3 分钟前</b></span>\n </div>\n <div class=\"spacer\"></div>\n <div class=\"dmb-main-tb\">\n <button class=\"icbtn\" type=\"button\" title=\"搜索批次\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/></svg>\n </button>\n <button class=\"chip\" type=\"button\">时间 <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></button>\n <button class=\"chip\" type=\"button\">状态 <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></button>\n <button class=\"chip\" type=\"button\">模特 <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></button>\n </div>\n </div>\n </div>\n\n <!-- 任务流 -->\n <div class=\"dmb-stream\">\n\n <div class=\"dmb-day-h\">\n <span>今天</span>\n <span class=\"ct\">3 批 · 10 张</span>\n </div>\n\n <!-- 批次 1 -->\n <div class=\"dmb-batch\">\n <div class=\"dmb-batch-h\">\n <div class=\"pic\">4×</div>\n <div class=\"meta\">\n <div class=\"nm\">Ava × 4 张 <span class=\"pill ok stat-pill\"><span class=\"dot\"></span>已完成</span></div>\n <div class=\"info\">\n <span>3:4</span><span class=\"sep\">·</span>\n <span>3 分钟前</span><span class=\"sep\">·</span>\n <span>¥1.20</span>\n </div>\n </div>\n <div class=\"ops\">\n <button type=\"button\" title=\"全部重跑\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5\"/></svg></button>\n <button type=\"button\" title=\"全部下载\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3\"/></svg></button>\n <button type=\"button\" title=\"加入资产库\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z\"/></svg></button>\n </div>\n </div>\n <div class=\"dmb-batch-grid\">\n <div class=\"dmb-cell\"><div class=\"ph\">Ava · #1</div><span class=\"tag\">3:4</span></div>\n <div class=\"dmb-cell\"><div class=\"ph\">Ava · #2</div><span class=\"tag\">3:4</span></div>\n <div class=\"dmb-cell\"><div class=\"ph\">Ava · #3</div><span class=\"tag\">3:4</span></div>\n <div class=\"dmb-cell\"><div class=\"ph\">Ava · #4</div><span class=\"tag\">3:4</span></div>\n </div>\n </div>\n\n <!-- 批次 2 -->\n <div class=\"dmb-batch\">\n <div class=\"dmb-batch-h\">\n <div class=\"pic\">4×</div>\n <div class=\"meta\">\n <div class=\"nm\">Zoe × 4 张 <span class=\"pill ok stat-pill\"><span class=\"dot\"></span>已完成</span></div>\n <div class=\"info\">\n <span>3:4</span><span class=\"sep\">·</span>\n <span>12 分钟前</span><span class=\"sep\">·</span>\n <span>¥1.20</span>\n </div>\n </div>\n <div class=\"ops\">\n <button type=\"button\" title=\"全部重跑\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5\"/></svg></button>\n <button type=\"button\" title=\"全部下载\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3\"/></svg></button>\n <button type=\"button\" title=\"加入资产库\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z\"/></svg></button>\n </div>\n </div>\n <div class=\"dmb-batch-grid\">\n <div class=\"dmb-cell\"><div class=\"ph\">Zoe · #1</div><span class=\"tag\">3:4</span></div>\n <div class=\"dmb-cell\"><div class=\"ph\">Zoe · #2</div><span class=\"tag\">3:4</span></div>\n <div class=\"dmb-cell\"><div class=\"ph\">Zoe · #3</div><span class=\"tag\">3:4</span></div>\n <div class=\"dmb-cell\"><div class=\"ph\">Zoe · #4</div><span class=\"tag\">3:4</span></div>\n </div>\n </div>\n\n <!-- 批次 3 · 生成中 -->\n <div class=\"dmb-batch\">\n <div class=\"dmb-batch-h\">\n <div class=\"pic\">2×</div>\n <div class=\"meta\">\n <div class=\"nm\">Ben × 2 张 <span class=\"pill info stat-pill\"><span class=\"dot\"></span>生成中</span></div>\n <div class=\"info\">\n <span>3:4</span><span class=\"sep\">·</span>\n <span>刚刚</span><span class=\"sep\">·</span>\n <span>¥0.60</span>\n </div>\n </div>\n <div class=\"ops\">\n <button type=\"button\" title=\"取消\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6 6l12 12M6 18L18 6\"/></svg></button>\n </div>\n </div>\n <div class=\"dmb-batch-grid\">\n <div class=\"dmb-cell gen\"><div class=\"ph\">生成中…</div></div>\n <div class=\"dmb-cell gen\"><div class=\"ph\">生成中…</div></div>\n </div>\n </div>\n\n <!-- 昨天 -->\n <div class=\"dmb-day-h\">\n <span>昨天</span>\n <span class=\"ct\">2 批 · 8 张</span>\n </div>\n\n <div class=\"dmb-batch\">\n <div class=\"dmb-batch-h\">\n <div class=\"pic\">4×</div>\n <div class=\"meta\">\n <div class=\"nm\">Lin × 4 张 <span class=\"pill ok stat-pill\"><span class=\"dot\"></span>已完成</span></div>\n <div class=\"info\">\n <span>3:4</span><span class=\"sep\">·</span>\n <span>昨天 18:24</span><span class=\"sep\">·</span>\n <span>¥1.20</span>\n </div>\n </div>\n <div class=\"ops\">\n <button type=\"button\" title=\"全部重跑\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5\"/></svg></button>\n <button type=\"button\" title=\"全部下载\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3\"/></svg></button>\n <button type=\"button\" title=\"加入资产库\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z\"/></svg></button>\n </div>\n </div>\n <div class=\"dmb-batch-grid\">\n <div class=\"dmb-cell\"><div class=\"ph\">Lin · #1</div><span class=\"tag\">3:4</span></div>\n <div class=\"dmb-cell\"><div class=\"ph\">Lin · #2</div><span class=\"tag\">3:4</span></div>\n <div class=\"dmb-cell\"><div class=\"ph\">Lin · #3</div><span class=\"tag\">3:4</span></div>\n <div class=\"dmb-cell\"><div class=\"ph\">Lin · #4</div><span class=\"tag\">3:4</span></div>\n </div>\n </div>\n\n <!-- 更早 -->\n <div class=\"dmb-day-h\">\n <span>更早</span>\n <span class=\"ct\">1 批 · 2 张 · 含 1 失败</span>\n </div>\n\n <div class=\"dmb-batch\">\n <div class=\"dmb-batch-h\">\n <div class=\"pic\">2×</div>\n <div class=\"meta\">\n <div class=\"nm\">Ava × 2 张 <span class=\"pill err stat-pill\"><span class=\"dot\"></span>失败</span></div>\n <div class=\"info\">\n <span>3:4</span><span class=\"sep\">·</span>\n <span>2 天前</span>\n </div>\n </div>\n <div class=\"ops\">\n <button type=\"button\" title=\"全部重跑\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5\"/></svg></button>\n <button type=\"button\" title=\"删除\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/></svg></button>\n </div>\n </div>\n <div class=\"dmb-batch-grid\">\n <div class=\"dmb-cell err\"><div class=\"ph\">失败 · 点重跑</div></div>\n <div class=\"dmb-cell err\"><div class=\"ph\">失败 · 点重跑</div></div>\n </div>\n </div>\n\n </div>\n\n <!-- 底部 fixed 参数面板 -->\n <div class=\"dmb-param-wrap\">\n <div class=\"dmb-param\">\n <button class=\"pchip active\" type=\"button\">\n <span class=\"lbl-mono\">模特</span>\n <span>Ava</span>\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg>\n </button>\n <button class=\"pchip\" type=\"button\">\n <span class=\"lbl-mono\">张数</span>\n <span>4</span>\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg>\n </button>\n <button class=\"pchip\" type=\"button\">\n <span class=\"lbl-mono\">比例</span>\n <span>3:4</span>\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg>\n </button>\n <button class=\"pchip\" type=\"button\">\n <span class=\"lbl-mono\">补充提示词</span>\n <span style=\"color:var(--black-alpha-48)\">+ 添加</span>\n </button>\n <span class=\"spacer\"></span>\n <span class=\"meta-right\">预估 <span class=\"v\">¥1.20</span> · 余额 <span class=\"v\">¥327.40</span></span>\n <button class=\"gen-btn\" type=\"button\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z\"/></svg>\n 生成 · 透真补水面膜 × Ava\n </button>\n </div>\n </div>\n\n </section>\n\n </div>\n\n</div>\n\n<script src=\"/exact/assets/icons.js?v=2026052608\"></script>\n<script src=\"/exact/assets/shell.js?v=2026060101\"></script>\n<script>\nShell.render({\n active: 'asset-factory',\n crumbs: [\n { label: '工作台', href: 'index.html' },\n { label: '图片生成', href: 'asset-factory.html' },\n { label: '模特上身图 · 方案 A · v2' }\n ]\n});\n\n// 商品 / chip 切换\ndocument.querySelectorAll('.dmb-prod').forEach(el => {\n el.addEventListener('click', () => {\n document.querySelectorAll('.dmb-prod').forEach(x => x.classList.remove('active'));\n el.classList.add('active');\n });\n});\ndocument.querySelectorAll('.dmb-param .pchip').forEach(el => {\n el.addEventListener('click', () => {\n // demo:只切自己 active(不互斥,每个 chip 都可独立切下拉)\n el.classList.toggle('active');\n });\n});\n</script>\n<script src=\"/exact/assets/api-bridge.js?v=2026060114\"></script>\n</body>\n</html>\n",
|
||
"pipeline": "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta name=\"x-airshelf-exact-source\" content=\"pipeline.html\">\n<meta charset=\"utf-8\">\n<title id=\"page-title\">流水线 · Airshelf</title>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"/exact/assets/restraint.css?v=2026060101\">\n<style>\n /* ─── Project header ─── */\n .proj-head { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 22px; align-items: flex-start; }\n .proj-head h1 { font-size: 20px; font-weight: 700; letter-spacing: -.012em; }\n\n /* ─── 顶部胶囊式 Stage 状态 · 注入到 .topbar 中部 ─── */\n .topbar { position: relative; } /* 锚定 pill */\n .pipeline-topbar-left {\n display: inline-flex;\n align-items: center;\n gap: 12px;\n min-width: 0;\n max-width: min(36vw, 520px);\n }\n .pipeline-back {\n height: 34px;\n padding: 0 13px 0 11px;\n border-radius: var(--r-pill);\n flex: 0 0 auto;\n }\n .pipeline-back svg { width: 14px; height: 14px; }\n .pipeline-topbar-title {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: 13.5px;\n font-weight: 500;\n color: var(--accent-black);\n }\n .pipeline-topbar-title .mono {\n margin-left: 8px;\n font-size: 10.5px;\n font-weight: 400;\n letter-spacing: .04em;\n color: var(--black-alpha-48);\n }\n @media (max-width: 1500px) {\n .pipeline-topbar-title { display: none; }\n }\n .stage-pill {\n position: absolute; left: 50%; top: 50%;\n transform: translate(-50%, -50%);\n display: inline-flex; align-items: center; gap: 0;\n padding: 6px 16px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-pill);\n z-index: 3;\n }\n .stage-pill .sp-dot {\n position: relative;\n display: inline-flex; align-items: center; gap: 6px;\n padding: 2px 8px;\n text-decoration: none;\n cursor: pointer;\n border-radius: var(--r-sm);\n transition: background var(--t-base);\n }\n .stage-pill .sp-dot:hover { background: var(--background-lighter); }\n /* 圆点本体 · 默认(待开始):浅灰实心 · 对齐 .prog span 默认 */\n .stage-pill .sp-dot .d {\n width: 10px; height: 10px; border-radius: 50%;\n background: var(--black-alpha-8);\n border: 1.5px solid transparent;\n transition: background var(--t-base), border-color var(--t-base), box-shadow var(--t-base);\n }\n /* 文字标签 · 始终显示 */\n .stage-pill .sp-dot .l {\n font-size: 12px; color: var(--black-alpha-56);\n font-weight: 500; letter-spacing: .01em;\n white-space: nowrap;\n transition: color var(--t-base);\n }\n .stage-pill .sp-dot:hover .l { color: var(--accent-black); }\n /* done · 森林绿 · 对齐 .prog span.done */\n .stage-pill .sp-dot.done .d {\n background: var(--accent-forest);\n border-color: var(--accent-forest);\n }\n .stage-pill .sp-dot.done .l { color: var(--accent-black); }\n /* active · 主橙 + 光晕 + 脉动 · 对齐 .prog span.cur(单橙锚点) */\n .stage-pill .sp-dot.active .d {\n background: var(--heat);\n border-color: var(--heat);\n box-shadow: 0 0 0 3px var(--heat-12);\n animation: prog-pulse 1.4s ease-in-out infinite;\n }\n .stage-pill .sp-dot.active .l { color: var(--heat); font-weight: 600; }\n /* fail · crimson · 对齐 .prog span.fail */\n .stage-pill .sp-dot.fail .d {\n background: var(--accent-crimson);\n border-color: var(--accent-crimson);\n }\n .stage-pill .sp-dot.fail .l { color: var(--accent-crimson); }\n /* 连接线 · 对齐 .prog 色卡 */\n .stage-pill .sp-line {\n width: 14px; height: 1.5px;\n background: var(--black-alpha-8);\n transition: background var(--t-base);\n }\n .stage-pill .sp-line.done { background: var(--accent-forest); }\n /* anchor 节点本身只承载初始模板,挂入 topbar 后变 .stage-pill */\n .stage-pill-anchor[hidden] { display: none; }\n\n /* ─── Stage panes ─── */\n .stage { display: none; }\n .stage.active { display: block; }\n\n /* Common pane */\n .pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); }\n .pane-h { display: flex; align-items: center; gap: 8px; padding: 14px 18px; border-bottom: 1px solid var(--border-faint); }\n .pane-h strong { font-size: 14px; font-weight: 600; }\n\n /* Stage foot */\n .stage-foot { display: flex; justify-content: space-between; align-items: center; padding: 18px 0 0; margin-top: 18px; border-top: 1px solid var(--border-faint); }\n .stage-foot .info { font-size: 12.5px; color: var(--black-alpha-56); }\n .stage-foot .info .mono { font-family: var(--font-mono); color: var(--black-alpha-48); font-size: 11.5px; letter-spacing: .02em; }\n .stage-foot .hstack { gap: 10px; align-items: center; }\n .stage-foot .btn {\n height: 40px;\n min-height: 40px;\n padding: 0 18px;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 7px;\n font-size: 13.5px;\n line-height: 1;\n white-space: nowrap;\n }\n .stage-foot .btn-primary { padding: 0 20px; font-weight: 600; }\n .stage-foot .btn svg { width: 14px; height: 14px; flex: 0 0 14px; }\n\n /* ─── 全高度布局 · 除 Stage 2 外,操作模块 hug content、内容区域 fill content ─── */\n /* JS 在 activateStage(1/3/4/5) 时给 .content 加 .content--fh,Stage 2 留常规文档流 */\n .content.content--fh {\n display: flex; flex-direction: column;\n overflow: hidden; /* 内部 .stage 接管滚动 */\n padding: 24px 28px 20px; /* 默认上下 padding · 让 stage-foot 真正贴近视口底部 */\n }\n /* flat 模式 · 像 model-photo 那样的双区扁平布局 · 取消卡片,全宽贴边 */\n .content.content--fh-flat {\n padding: 0;\n }\n /* flat 模式下 · 取消 .pane 卡片描边/圆角(Stage 1 两侧面板) */\n .content--fh-flat .stage.active > .stage-script .pane {\n background: var(--surface);\n border: 0; border-radius: 0;\n }\n /* 左/右 pane 白底贴边 · 仅内部元素对齐到 topbar 28px 版心 */\n /* Stage 1 · 镜头脚本 + 脚本助手 */\n .content--fh-flat .stage[data-stage-pane=\"1\"].active > .stage-script > .pane.shot-list > .pane-h,\n .content--fh-flat .stage[data-stage-pane=\"1\"].active > .stage-script > .pane.shot-list > .shots-body {\n padding-left: 28px;\n }\n .content--fh-flat .stage[data-stage-pane=\"1\"].active > .stage-script > .pane.chat-pane > .pane-h,\n .content--fh-flat .stage[data-stage-pane=\"1\"].active > .stage-script > .pane.chat-pane > .chat-body,\n .content--fh-flat .stage[data-stage-pane=\"1\"].active > .stage-script > .pane.chat-pane > .chat-input {\n padding-left: 28px;\n padding-right: 28px;\n }\n /* Stage 1 · flat 模式去除分割线:两侧 pane-h 底线 · gutter 竖线 · chat-input 顶线 */\n .content--fh-flat .stage[data-stage-pane=\"1\"].active > .stage-script > .pane.shot-list > .pane-h,\n .content--fh-flat .stage[data-stage-pane=\"1\"].active > .stage-script > .pane.chat-pane > .pane-h {\n border-bottom: 0;\n }\n .content--fh-flat .stage[data-stage-pane=\"1\"].active > .stage-script > .pane.chat-pane > .chat-input {\n border-top: 0;\n }\n .content--fh-flat .stage[data-stage-pane=\"1\"].active > .stage-script > .stage-script-gutter::after {\n background: transparent;\n }\n .content--fh-flat .stage[data-stage-pane=\"1\"].active > .stage-script > .stage-script-gutter:hover::after,\n .content--fh-flat .stage[data-stage-pane=\"1\"].active > .stage-script > .stage-script-gutter.dragging::after {\n background: var(--heat);\n }\n /* Stage 2/3 的内部对齐规则放到对应 flat 块里(避免被后面规则吃掉) */\n /* Stage 5 · editor-preview 左 · editor-props 右 · timeline 两侧 */\n .content--fh-flat .stage[data-stage-pane=\"5\"].active > .editor > .editor-preview {\n padding-left: 28px;\n }\n .content--fh-flat .stage[data-stage-pane=\"5\"].active > .editor > .editor-props {\n padding-right: 28px;\n }\n .content--fh-flat .stage[data-stage-pane=\"5\"].active > .editor > .timeline {\n padding-left: 28px;\n padding-right: 28px;\n }\n /* 镜头脚本 | 拖拽分隔条 | 脚本助手 三列布局 · 分隔条可拖动 */\n .content--fh-flat .stage[data-stage-pane=\"1\"].active > .stage-script {\n gap: 0;\n grid-template-columns: minmax(0, 1fr) 6px var(--chat-w, 520px);\n }\n /* 拖拽 gutter · 默认 1px 灰线,hover/拖中加重 · 鼠标 col-resize */\n .stage-script-gutter {\n position: relative;\n background: transparent;\n cursor: col-resize;\n transition: background var(--t-base);\n }\n .stage-script-gutter::after {\n content: '';\n position: absolute; top: 0; bottom: 0; left: 50%;\n width: 1px; transform: translateX(-50%);\n background: var(--border-faint);\n transition: background var(--t-base), width var(--t-base);\n }\n .stage-script-gutter:hover::after,\n .stage-script-gutter.dragging::after {\n background: var(--heat);\n width: 2px;\n }\n .stage-script-gutter.dragging { background: var(--heat-12); }\n /* flat 模式 · stage-foot 改为全宽 flat 底栏:白底 + border-top,不再像卡片下沿 */\n .content--fh-flat .stage.active > .stage-foot {\n margin-top: 0;\n padding: 14px 28px;\n background: var(--surface);\n border-top: 1px solid var(--border-faint);\n }\n\n /* ─── Stage 3 flat · 故事板双区:左 canvas | 右 side,用 border-right 分隔 ─── */\n .content--fh-flat .stage[data-stage-pane=\"3\"].active > .stage-storyboard {\n gap: 0;\n }\n .content--fh-flat .stage[data-stage-pane=\"3\"].active > .stage-storyboard > .sb-canvas {\n border: 0; border-radius: 0;\n background: var(--surface);\n padding: 18px 14px 18px 28px;\n align-items: center;\n }\n .content--fh-flat .stage[data-stage-pane=\"3\"].active > .stage-storyboard > .sb-canvas > .sb-main-img {\n width: 100%;\n }\n /* sb-side · 撑满网格行高 · 内 .pane fill content + 内部可滚 */\n .content--fh-flat .stage[data-stage-pane=\"3\"].active > .stage-storyboard > .sb-side {\n display: flex; flex-direction: column; min-height: 0;\n }\n .content--fh-flat .stage[data-stage-pane=\"3\"].active > .stage-storyboard > .sb-side > .pane {\n flex: 1 1 0; min-height: 0; overflow-y: auto;\n border: 0; border-radius: 0;\n background: var(--surface);\n padding: 18px 28px;\n }\n /* 解除 sb-scenes-col 560 上限,跟随父级填满 */\n .content--fh-flat .stage[data-stage-pane=\"3\"].active .sb-scenes-col { max-height: none; }\n\n /* ─── Stage 2 flat · 左 200 资产侧栏 | 右 内容,用 border-right 分隔 ─── */\n .content--fh-flat .stage[data-stage-pane=\"2\"].active > .stage-assets {\n gap: 0;\n height: 100%;\n }\n .content--fh-flat .stage[data-stage-pane=\"2\"].active > .stage-assets > .asset-side {\n position: static; align-self: stretch;\n padding: 18px 16px;\n background: var(--background-base);\n overflow-y: auto;\n }\n .content--fh-flat .stage[data-stage-pane=\"2\"].active > .stage-assets > .asset-main {\n padding: 18px 28px;\n overflow-y: auto;\n background: var(--background-base);\n }\n\n /* ─── Stage 4 flat · 顶部 queue-bar 改 toolbar · 视频卡片网格区 ─── */\n .content--fh-flat .stage[data-stage-pane=\"4\"].active > .queue-bar {\n border: 0; border-radius: 0;\n border-bottom: 1px solid var(--border-faint);\n margin: 0;\n padding: 14px 28px;\n }\n .content--fh-flat .stage[data-stage-pane=\"4\"].active > .video-grid {\n padding: 18px 28px;\n background: var(--background-base);\n }\n\n /* ─── Stage 5 flat · 编辑器外壳去卡片 ─── */\n .content--fh-flat .stage[data-stage-pane=\"5\"].active > .editor {\n border: 0; border-radius: 0;\n }\n\n .content--fh .stage.active {\n display: flex; flex-direction: column;\n flex: 1 1 auto; min-height: 0;\n }\n /* 内容主体(stage-foot 之上的最后一个块)· fill content */\n .content--fh .stage[data-stage-pane=\"1\"].active > .stage-script,\n .content--fh .stage[data-stage-pane=\"2\"].active > .stage-assets,\n .content--fh .stage[data-stage-pane=\"3\"].active > .stage-storyboard,\n .content--fh .stage[data-stage-pane=\"4\"].active > .video-grid,\n .content--fh .stage[data-stage-pane=\"5\"].active > .editor {\n flex: 1 1 0; min-height: 0;\n }\n /* Stage 1 / 2 主体不整体滚,把滚动交给左右栏各自的 body */\n .content--fh .stage[data-stage-pane=\"1\"].active > .stage-script,\n .content--fh .stage[data-stage-pane=\"2\"].active > .stage-assets { overflow: hidden; }\n /* 其他 stage 主体仍可整体滚动 */\n .content--fh .stage[data-stage-pane=\"3\"].active > .stage-storyboard,\n .content--fh .stage[data-stage-pane=\"4\"].active > .video-grid,\n .content--fh .stage[data-stage-pane=\"5\"].active > .editor { overflow-y: auto; }\n /* stage-foot · hug content:高度只由按钮决定,不拉伸、不压缩 */\n .content--fh .stage.active > .stage-foot { flex: 0 0 auto; }\n /* Stage 1 脚本网格:取消固定 min-height,允许 flex 接管 */\n .content--fh .stage[data-stage-pane=\"1\"].active > .stage-script { min-height: 0; }\n /* Stage 1 内部 panes:左右栏各自独立滚动 */\n .content--fh .stage[data-stage-pane=\"1\"].active > .stage-script > .shot-list,\n .content--fh .stage[data-stage-pane=\"1\"].active > .stage-script > .chat-pane {\n min-height: 0; min-width: 0;\n }\n .content--fh .stage[data-stage-pane=\"1\"].active .shots-body,\n .content--fh .stage[data-stage-pane=\"1\"].active .chat-body {\n max-height: none;\n flex: 1 1 0; min-height: 0; overflow-y: auto;\n }\n /* Stage 5 编辑器:解除固定 580px 高,让 flex 接管 */\n .content--fh .stage[data-stage-pane=\"5\"].active > .editor { height: auto; }\n\n /* === STAGE 1 · 脚本(镜头脚本 : 脚本助手 = 7 : 3,助手在 3:2 基础上再缩 1/4) === */\n .stage-script { display: grid; grid-template-columns: 7fr 3fr; gap: 16px; min-height: 560px; }\n\n .chat-pane { display: flex; flex-direction: column; }\n .chat-body { padding: 16px 18px; flex: 1; overflow-y: auto; max-height: 460px; display: flex; flex-direction: column; gap: 14px; }\n .msg .bubble { max-width: 90%; padding: 10px 14px; font-size: 13px; line-height: 1.6; border: 1px solid var(--border-faint); border-radius: var(--r-md); }\n .msg.ai .bubble { background: var(--surface); }\n .msg.user { display: flex; flex-direction: column; align-items: flex-end; }\n .msg.user .bubble { background: var(--heat-12); color: var(--accent-black); border-color: var(--heat-20); }\n .msg .time { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); margin-top: 4px; letter-spacing: .02em; }\n .msg .actions { display: flex; gap: 6px; margin-top: 6px; }\n .ai-avatar { width: 26px; height: 26px; flex-shrink: 0; background: var(--heat); color: var(--accent-white); display: grid; place-items: center; font-size: 11px; font-weight: 700; border: 1px solid var(--heat); border-radius: 50%; }\n .del { text-decoration: line-through; color: var(--black-alpha-48); }\n .ins { background: var(--forest-bg); color: var(--accent-forest); padding: 0 3px; }\n .chat-input { padding: 14px 18px 18px; border-top: 1px solid var(--border-faint); }\n .chat-input-card {\n background: var(--background-base);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n padding: 12px 14px 10px;\n transition: border-color var(--t-base), box-shadow var(--t-base);\n }\n .chat-input-card:focus-within { border-color: var(--accent-black); box-shadow: 0 0 0 3px rgba(0,0,0,.04); }\n .chat-input-area {\n width: 100%; border: none; outline: none; background: transparent;\n font-family: var(--font-sans); font-size: 13px; color: var(--accent-black);\n line-height: 1.55; resize: none; padding: 0; min-height: 42px;\n }\n .chat-input-area::placeholder { color: var(--black-alpha-40); }\n .chat-input-foot { display: flex; align-items: center; gap: 8px; margin-top: 10px; }\n .chat-input-foot .hint { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-40); letter-spacing: .02em; }\n .chat-input-foot .spacer { flex: 1; }\n .chat-icon-btn {\n width: 28px; height: 28px; display: grid; place-items: center;\n background: transparent; border: 1px solid var(--border-faint);\n border-radius: 50%; color: var(--black-alpha-56); cursor: pointer;\n transition: border-color var(--t-base), color var(--t-base), background var(--t-base);\n }\n .chat-icon-btn:hover { border-color: var(--accent-black); color: var(--accent-black); }\n .chat-send-btn {\n width: 32px; height: 32px; display: grid; place-items: center;\n background: var(--accent-black); border: 1px solid var(--accent-black);\n border-radius: 50%; color: var(--accent-white); cursor: pointer;\n transition: background var(--t-base), border-color var(--t-base), transform var(--t-base);\n }\n .chat-send-btn:hover { background: var(--heat); border-color: var(--heat); }\n .chat-send-btn:active { transform: scale(.95); }\n .chat-send-btn:disabled { background: var(--black-alpha-12); border-color: var(--black-alpha-12); color: var(--black-alpha-40); cursor: not-allowed; transform: none; }\n .chat-attach-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }\n .chat-attach-chip {\n display: inline-flex; align-items: center; gap: 6px;\n padding: 3px 6px 3px 8px; background: var(--surface);\n border: 1px solid var(--border-faint); border-radius: var(--r-sm);\n font-family: var(--font-mono); font-size: 11px; color: var(--accent-black);\n }\n .chat-attach-chip .x { width: 14px; height: 14px; display: grid; place-items: center; background: transparent; border: none; color: var(--black-alpha-48); cursor: pointer; border-radius: 50%; }\n .chat-attach-chip .x:hover { background: var(--black-alpha-08); color: var(--accent-black); }\n\n .shot-list { display: flex; flex-direction: column; }\n .shots-body { padding: 12px 16px; flex: 1; overflow-y: auto; max-height: 540px; display: flex; flex-direction: column; gap: 0; }\n .shot-card { background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 12px 14px; transition: border-color var(--t-base), background var(--t-base); }\n .shot-card.highlight { border-color: var(--heat); background: var(--heat-12); }\n .shot-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }\n .shot-num { width: 22px; height: 22px; background: var(--accent-black); color: var(--accent-white); display: grid; place-items: center; font-family: var(--font-mono); font-size: 11px; font-weight: 700; border-radius: var(--r-sm); }\n .shot-time { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); padding: 2px 6px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); }\n .shot-row { display: grid; grid-template-columns: 36px 1fr; gap: 8px; padding: 4px 0; }\n .shot-k { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); padding-top: 2px; letter-spacing: .04em; }\n .shot-v { font-size: 12.5px; color: var(--accent-black); line-height: 1.55; outline: none; border-radius: var(--r-sm); padding: 2px 4px; margin: -2px -4px; transition: background var(--t-base); }\n .shot-v[contenteditable=\"true\"]:hover { background: var(--heat-12); cursor: text; }\n .shot-v[contenteditable=\"true\"]:focus { background: var(--surface); box-shadow: inset 0 0 0 1px var(--heat); }\n .shot-v[data-empty=\"true\"]::before { content: attr(data-placeholder); color: var(--black-alpha-32); font-style: italic; }\n .icon-mini-btn { width: 24px; height: 24px; display: grid; place-items: center; color: var(--black-alpha-48); background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); cursor: pointer; font-size: 14px; }\n .icon-mini-btn:hover { color: var(--heat); border-color: var(--heat); }\n\n /* 镜头卡片间 hover 加分镜插槽 */\n /* 镜头卡片间 hover 加分镜插槽 · 卡片移开一行 + 渐显按钮 */\n .shot-insert-gap {\n height: 10px;\n position: relative;\n display: flex; align-items: center; justify-content: center;\n padding: 0;\n transition: height .24s cubic-bezier(.18,.72,.28,1), padding .24s cubic-bezier(.18,.72,.28,1);\n }\n .shot-insert-gap:hover {\n height: 72px;\n padding: 14px 0;\n }\n .shot-insert-gap .add-shot-btn {\n opacity: 0;\n transform: translateY(4px) scale(.96);\n height: 28px; padding: 0 14px;\n background: var(--surface);\n color: var(--heat);\n border: 1px dashed var(--heat-40);\n border-radius: var(--r-md);\n font-size: 12.5px; font-family: inherit; font-weight: 500;\n cursor: pointer;\n transition: opacity .2s ease .04s, transform .24s cubic-bezier(.18,.72,.28,1) .04s, background var(--t-base), border-color var(--t-base), color var(--t-base);\n display: inline-flex; align-items: center; gap: 6px;\n pointer-events: none;\n white-space: nowrap;\n }\n .shot-insert-gap .add-shot-btn svg { width: 12px; height: 12px; }\n .shot-insert-gap:hover .add-shot-btn {\n opacity: 1; transform: translateY(0) scale(1);\n pointer-events: auto;\n }\n .shot-insert-gap .add-shot-btn:hover {\n background: var(--heat-12);\n border-style: solid;\n border-color: var(--heat);\n color: var(--heat);\n }\n\n /* 镜头脚本顶栏 · 自动从脚本抓取的人物/场景标签 · 可编辑/删除/添加 */\n .shot-list > .pane-h { flex-wrap: wrap; row-gap: 8px; }\n .shot-headline { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }\n .script-brief-summary { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; }\n .script-brief-pill { gap: 4px; padding: 3px 8px; font-size: 11px; }\n .script-brief-pill .k { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-48); letter-spacing: .04em; }\n .script-brief-pill .v { color: var(--accent-black); max-width: 116px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .script-tags { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 14px; margin-left: 6px; }\n .script-tags .tag-group { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }\n .script-tags .tg-lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; flex-shrink: 0; }\n .script-tags .script-tag { display: inline-flex; align-items: center; gap: 2px; padding: 2px 2px 2px 10px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: 999px; font-size: 11.5px; color: var(--accent-black); transition: border-color var(--t-base), background var(--t-base); }\n .script-tags .script-tag:hover { border-color: var(--heat-40); background: var(--heat-12); }\n .script-tags .script-tag .t { outline: none; padding: 0 4px; border-radius: 3px; min-width: 8px; cursor: text; }\n .script-tags .script-tag .t:focus { background: var(--surface); box-shadow: inset 0 0 0 1px var(--heat); }\n .script-tags .script-tag .x { width: 16px; height: 16px; display: grid; place-items: center; background: transparent; border: 0; color: var(--black-alpha-40); border-radius: 50%; cursor: pointer; font-size: 13px; line-height: 1; transition: background var(--t-base), color var(--t-base); }\n .script-tags .script-tag .x:hover { background: var(--black-alpha-08); color: var(--accent-crimson); }\n .script-tags .tag-add { width: 20px; height: 20px; display: grid; place-items: center; background: transparent; border: 1px dashed var(--black-alpha-24); border-radius: 50%; color: var(--black-alpha-48); cursor: pointer; font-size: 13px; line-height: 1; padding: 0; transition: border-color var(--t-base), color var(--t-base), background var(--t-base); }\n .script-tags .tag-add:hover { border-style: solid; border-color: var(--heat); color: var(--heat); background: var(--heat-12); }\n\n /* 镜头脚本空缺省态 */\n .shots-empty { padding: 36px 24px; margin: auto; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 12px; color: var(--black-alpha-48); }\n .shots-empty .empty-ico { width: 56px; height: 56px; border: 1px dashed var(--border-faint); border-radius: var(--r-md); display: grid; place-items: center; color: var(--black-alpha-32); }\n .shots-empty .empty-title { font-size: 14px; font-weight: 500; color: var(--accent-black); }\n .shots-empty .empty-hint { font-size: 12px; color: var(--black-alpha-56); line-height: 1.55; max-width: 280px; font-family: var(--font-mono); letter-spacing: .02em; }\n\n /* 对话空态三胶囊 */\n .chat-empty { padding: 28px 18px 14px; margin: auto; display: flex; flex-direction: column; align-items: center; gap: 12px; }\n .chat-empty .ce-title { font-size: 13.5px; color: var(--accent-black); font-weight: 500; }\n .chat-empty .ce-hint { font-size: 11.5px; color: var(--black-alpha-56); font-family: var(--font-mono); letter-spacing: .02em; }\n .chat-modes { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }\n .chat-mode { height: 30px; padding: 0 14px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: 999px; font-size: 12.5px; color: var(--accent-black); display: inline-flex; align-items: center; gap: 6px; cursor: pointer; font-family: inherit; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }\n .chat-mode:hover { background: var(--heat-12); border-color: var(--heat); color: var(--heat); }\n .chat-mode.primary { background: var(--heat-12); border-color: var(--heat); color: var(--heat); }\n .chat-mode svg { width: 13px; height: 13px; }\n .script-brief-card { margin-top: 8px; padding: 12px; background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-md); display: flex; flex-direction: column; gap: 10px; }\n .script-brief-row { display: grid; grid-template-columns: 56px minmax(0, 1fr); column-gap: 10px; row-gap: 4px; align-items: center; }\n .script-brief-row .k { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; }\n .script-brief-row .why { grid-column: 2 / 3; font-size: 11.5px; color: var(--black-alpha-48); line-height: 1.5; }\n .script-brief-select { position: relative; display: inline-flex; width: 100%; min-width: 0; }\n .script-brief-value {\n width: 100%;\n min-width: 0;\n height: 36px;\n padding: 0 12px;\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n background: var(--surface);\n color: var(--accent-black);\n font-size: 13px;\n font-weight: 500;\n font-family: inherit;\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n transition: background var(--t-base), border-color var(--t-base), color var(--t-base);\n }\n .script-brief-value .v { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }\n .script-brief-value::after {\n content: '';\n width: 5px;\n height: 5px;\n border-right: 1px solid currentColor;\n border-bottom: 1px solid currentColor;\n transform: rotate(45deg) translateY(-1px);\n transition: transform var(--t-base);\n color: var(--black-alpha-48);\n flex-shrink: 0;\n }\n .script-brief-value:hover {\n background: var(--heat-12);\n border-color: var(--heat-20);\n color: var(--heat);\n }\n .script-brief-select.open .script-brief-value {\n background: var(--heat-12);\n border-color: var(--heat);\n color: var(--heat);\n }\n .script-brief-select.open .script-brief-value::after {\n color: var(--heat);\n transform: rotate(225deg) translate(-1px, -1px);\n }\n .script-brief-select.open .chip-menu { display: block; }\n .script-brief-select .chip-menu { min-width: 168px; right: 0; left: auto; z-index: 80; }\n .script-brief-select .chip-menu .mi { width: 100%; border: 0; background: transparent; font-family: inherit; text-align: left; }\n .script-brief-actions { display: grid; grid-template-columns: 56px minmax(0, 1fr); column-gap: 10px; align-items: center; padding-top: 2px; }\n .script-brief-actions .action-row { grid-column: 2 / 3; display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; }\n\n /* AI 思考态 typing indicator */\n .ai-thinking .dots { display: inline-flex; gap: 3px; }\n .ai-thinking .dots span { width: 6px; height: 6px; background: var(--black-alpha-32); border-radius: 50%; animation: thinking 1.2s ease-in-out infinite; }\n .ai-thinking .dots span:nth-child(2) { animation-delay: .2s; }\n .ai-thinking .dots span:nth-child(3) { animation-delay: .4s; }\n @keyframes thinking { 0%, 80%, 100% { opacity: .25; } 40% { opacity: 1; } }\n\n /* 视口锁定 · 只让主内容区滚动 (sidebar + topbar 固定,不随页面滚) */\n html, body { height: 100%; overflow: hidden; max-width: 100vw; }\n .app { height: 100vh; max-height: 100vh; overflow: hidden; }\n .app > .sidebar { height: 100vh; overflow-y: auto; }\n .app > main { height: 100vh; max-height: 100vh; overflow: hidden; display: flex; flex-direction: column; min-width: 0; }\n .app > main > .topbar { flex-shrink: 0; }\n .app > main > .content { flex: 1 1 0; min-height: 0; min-width: 0; overflow-y: auto; overflow-x: hidden; }\n\n /* === STAGE 2 · 基础资产 === */\n .stage-assets { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 24px; }\n .stage-assets > div { min-width: 0; }\n .asset-side { position: sticky; top: 16px; align-self: start; }\n .asset-sec { min-width: 0; }\n .asset-strip-wrap { min-width: 0; }\n .asset-side .ttab { padding: 10px 12px; font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 8px; border: 1px solid transparent; border-radius: var(--r-md); }\n .asset-side .ttab:hover { background: var(--background-lighter); }\n .asset-side .ttab.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); font-weight: 600; }\n .asset-side .ttab .num { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); margin-left: auto; }\n .asset-side .ttab.active .num { color: var(--heat); }\n .asset-side .info { font-size: 12px; color: var(--black-alpha-48); padding: 14px 12px; line-height: 1.6; margin-top: 14px; border-top: 1px solid var(--border-faint); }\n .asset-side .info strong { color: var(--black-alpha-56); display: block; }\n .asset-side .info .mono { font-family: var(--font-mono); }\n\n .asset-sec { scroll-margin-top: 16px; }\n .asset-sec + .asset-sec { margin-top: 32px; }\n .asset-sec .sec-h { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }\n .asset-sec .sec-h h3 { font-size: 15px; font-weight: 600; }\n /* .pill-tip 主样式定义在下方 (heat 主色) */\n\n /* 预设库横滑行(卡片尺寸与主区 .asset-card-2 一致) */\n .asset-strip-wrap { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-faint); }\n .asset-strip-wrap .strip-h { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-56); letter-spacing: .04em; text-transform: uppercase; }\n .asset-strip { display: flex; gap: 14px; overflow-x: auto; overflow-y: hidden; padding: 2px 2px 14px; scrollbar-width: thin; }\n .asset-strip::-webkit-scrollbar { height: 8px; }\n .asset-strip::-webkit-scrollbar-thumb { background: var(--border-faint); border-radius: 4px; }\n .asset-strip .asset-card-2 { flex: 0 0 240px; min-width: 240px; max-width: 240px; }\n\n /* 「去 XX 库」CTA 胶囊 · 主操作色,更显眼 */\n .asset-sec .sec-h .pill-tip,\n .asset-strip-wrap .strip-h .pill-tip {\n display: inline-flex; align-items: center; gap: 6px;\n height: 28px; padding: 0 14px;\n background: var(--heat-12);\n border: 1px solid var(--heat-20);\n border-radius: 999px;\n font-size: 12px; color: var(--heat); font-weight: 500;\n cursor: pointer; font-family: inherit;\n transition: background var(--t-base), color var(--t-base), border-color var(--t-base);\n }\n .asset-sec .sec-h .pill-tip:hover,\n .asset-strip-wrap .strip-h .pill-tip:hover {\n background: var(--heat); color: var(--accent-white); border-color: var(--heat);\n box-shadow: var(--shadow-cta);\n }\n .asset-sec .sec-h .pill-tip svg,\n .asset-strip-wrap .strip-h .pill-tip svg { width: 12px; height: 12px; }\n\n .asset-grid-2 { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }\n /* 商品行:左侧商品卡 + 右侧三视图预览(三视图是单张 16:9 图,不是 3 张) */\n .prod-row { display: flex; gap: 14px; align-items: flex-start; flex-wrap: wrap; }\n /* 三视图卡固定 360 高;商品卡同高,宽度按 3:5 比例反推(≈216px),内部元素 flex 自适应 */\n .prod-row > .asset-card-2 {\n flex: 0 0 auto;\n width: auto; max-width: none; min-width: 0;\n height: 360px;\n aspect-ratio: 3 / 5;\n }\n .prod-preview { flex: 0 0 360px; height: 360px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 12px; display: none; flex-direction: column; gap: 10px; }\n .prod-preview.show { display: flex; }\n .prod-preview-h { display: flex; align-items: center; gap: 8px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-56); letter-spacing: .04em; text-transform: uppercase; }\n .prod-preview-img { aspect-ratio: 16/9; }\n .prod-preview-foot { display: flex; align-items: center; gap: 8px; min-height: 30px; }\n /* 三视图历史版本缩略图 strip */\n .prod-preview-history { display: none; flex-direction: column; gap: 6px; }\n .prod-preview-history.show { display: flex; }\n .prod-preview-history .h-lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }\n .prod-preview-history .h-lbl .ct { color: var(--accent-black); font-weight: 600; }\n .prod-preview-history .h-row { display: flex; gap: 6px; overflow-x: auto; padding: 2px; scrollbar-width: thin; }\n .prod-preview-history .h-row::-webkit-scrollbar { height: 4px; }\n .prod-preview-history .h-row::-webkit-scrollbar-thumb { background: var(--border-faint); border-radius: 2px; }\n .prod-preview-history .h-thumb {\n flex: 0 0 auto;\n width: 72px; aspect-ratio: 16/9;\n background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm);\n position: relative; cursor: pointer; transition: border-color var(--t-base), transform var(--t-base);\n display: grid; place-items: center; overflow: hidden;\n }\n .prod-preview-history .h-thumb:hover { border-color: var(--heat-40); }\n /* 已采用版本:主橙描边 + 「已采用」徽标 */\n .prod-preview-history .h-thumb.adopted { border-color: var(--heat); border-width: 2px; box-shadow: 0 0 0 2px var(--heat-12); }\n /* 仅预览(未采用):黑色描边,无徽标 */\n .prod-preview-history .h-thumb.previewing { border-color: var(--accent-black); border-width: 2px; }\n .prod-preview-history .h-thumb .v { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-56); letter-spacing: .02em; }\n .prod-preview-history .h-thumb.adopted .v { color: var(--heat); font-weight: 600; }\n .prod-preview-history .h-thumb.previewing .v { color: var(--accent-black); font-weight: 600; }\n .prod-preview-history .h-thumb .badge { position: absolute; top: 2px; left: 2px; font-family: var(--font-mono); font-size: 8.5px; padding: 0 4px; line-height: 12px; background: var(--heat); color: var(--accent-white); border-radius: 2px; letter-spacing: .02em; display: none; }\n .prod-preview-history .h-thumb.adopted .badge { display: block; }\n\n /* 「已采用」状态 · 浅橙 + 主橙文字,与已采用徽标视觉呼应 */\n #prod-preview-adopt:disabled,\n #prod-preview-adopt:disabled:hover {\n color: var(--heat);\n border-color: var(--heat-40);\n background: var(--heat-12);\n cursor: not-allowed;\n opacity: 1;\n }\n\n /* 主图可点击放大 */\n .prod-preview-img.is-zoomable { cursor: zoom-in; transition: border-color var(--t-base); position: relative; }\n .prod-preview-img.is-zoomable:hover { border-color: var(--heat-40); }\n .prod-preview-img.is-zoomable::after {\n content: '';\n position: absolute; top: 8px; right: 8px;\n width: 22px; height: 22px;\n background: rgba(21,20,15,.72) url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='11' cy='11' r='7'/><path d='M21 21l-4.3-4.3M8 11h6M11 8v6'/></svg>\") center/14px no-repeat;\n border-radius: var(--r-sm);\n opacity: 0; transition: opacity var(--t-base);\n pointer-events: none;\n }\n .prod-preview-img.is-zoomable:hover::after { opacity: 1; }\n\n /* 三视图放大查看 lightbox */\n #tri-lightbox-bg { z-index: 80; }\n #tri-lightbox-bg .tri-lightbox {\n position: relative;\n width: min(1100px, 92vw);\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n padding: 18px 20px 20px;\n display: flex; flex-direction: column; gap: 12px;\n box-shadow: 0 24px 64px rgba(0,0,0,.24);\n }\n .tri-lightbox-head {\n display: flex; align-items: center; gap: 8px;\n font-family: var(--font-mono);\n font-size: 11px; letter-spacing: .04em; text-transform: uppercase;\n color: var(--black-alpha-56);\n padding-right: 32px;\n }\n .tri-lightbox-head .lb-ver { color: var(--heat); font-weight: 600; }\n .tri-lightbox-head .lb-tag {\n margin-left: 6px;\n padding: 2px 6px;\n background: var(--heat-12); color: var(--heat);\n border-radius: 3px;\n font-size: 10px;\n }\n .tri-lightbox-close {\n position: absolute;\n top: 12px; right: 12px;\n width: 28px; height: 28px;\n display: grid; place-items: center;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--black-alpha-56);\n cursor: pointer;\n transition: background var(--t-base), color var(--t-base), border-color var(--t-base);\n z-index: 2;\n }\n .tri-lightbox-close:hover { background: var(--black-alpha-08); color: var(--accent-black); border-color: var(--black-alpha-12); }\n .tri-lightbox-close svg { width: 14px; height: 14px; }\n .tri-lightbox-img { aspect-ratio: 16/9; width: 100%; }\n .tri-lightbox-foot { display: flex; align-items: center; gap: 8px; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); }\n .tri-lightbox-foot .spc { flex: 1; }\n .tri-lightbox-foot kbd {\n display: inline-block;\n padding: 1px 5px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-bottom-width: 2px;\n border-radius: 3px;\n font-family: var(--font-mono);\n font-size: 10px;\n color: var(--black-alpha-72);\n }\n .asset-card-2 { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: border-color var(--t-base), box-shadow var(--t-base); overflow: hidden; display: flex; flex-direction: column; }\n .asset-card-2:hover { border-color: var(--heat-40); box-shadow: 0 1px 3px rgba(0,0,0,.04); }\n .asset-card-2 .thumb-2 { aspect-ratio: 1; }\n .asset-card-2 .body-2 { padding: 12px 14px; }\n .asset-card-2 .body-2 .btn:disabled,\n .asset-card-2 .body-2 .btn.disabled {\n background: transparent;\n border-color: transparent;\n color: var(--black-alpha-32);\n box-shadow: none;\n cursor: not-allowed;\n opacity: .72;\n transform: none;\n }\n .asset-card-2 .body-2 .btn:disabled:hover,\n .asset-card-2 .body-2 .btn.disabled:hover {\n background: transparent;\n border-color: transparent;\n color: var(--black-alpha-32);\n box-shadow: none;\n transform: none;\n }\n .asset-card-2 .body-2 .btn-apply { background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); }\n .asset-card-2 .body-2 .btn-apply:hover { box-shadow: var(--shadow-cta-hover); }\n\n /* stage2 商品卡 · 与商品库 .product-card 视觉一致 */\n .asset-card-2.prod-lib-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }\n /* 商品图 · 占据卡片剩余高度(fill);宽度 stretch 到卡片宽 */\n .asset-card-2.prod-lib-card .prod-thumb { flex: 1 1 0; min-height: 0; position: relative; aspect-ratio: auto; }\n .asset-card-2.prod-lib-card .prod-body { padding: 14px 14px 12px; flex: 0 0 auto; }\n .asset-card-2.prod-lib-card .prod-name {\n font-size: 14px; font-weight: 600;\n color: var(--accent-black);\n line-height: 1.3;\n overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n }\n .asset-card-2.prod-lib-card .prod-cat {\n display: inline-flex; align-items: center;\n margin-top: 8px;\n padding: 2px 8px;\n background: var(--background-lighter);\n color: var(--black-alpha-72);\n border-radius: var(--r-sm);\n font-size: 11.5px;\n }\n .asset-card-2.prod-lib-card .prod-date {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--black-alpha-48);\n margin-top: 10px;\n letter-spacing: .02em;\n }\n .asset-card-2.prod-lib-card .prod-footer {\n display: grid;\n grid-template-columns: 1fr auto 1fr;\n align-items: center;\n column-gap: 8px;\n padding: 10px 12px;\n border-top: 1px solid var(--border-faint);\n font-size: 11.5px;\n color: var(--black-alpha-56);\n background: var(--background-base);\n }\n .asset-card-2.prod-lib-card .prod-footer .stat {\n display: inline-flex; align-items: center; justify-content: center; gap: 5px;\n padding: 3px 8px;\n border-radius: var(--r-sm);\n font-family: var(--font-mono);\n letter-spacing: .02em;\n white-space: nowrap;\n justify-self: center;\n }\n .asset-card-2.prod-lib-card .prod-footer .stat svg { width: 13px; height: 13px; color: var(--black-alpha-48); flex-shrink: 0; }\n .asset-card-2.prod-lib-card .prod-footer .stat b { color: var(--accent-black); font-weight: 600; }\n .asset-card-2.prod-lib-card .prod-footer .sep { color: var(--black-alpha-24); font-family: var(--font-mono); flex-shrink: 0; }\n .asset-card-2.prod-lib-card .prod-action {\n padding: 10px 12px;\n border-top: 1px solid var(--border-faint);\n background: var(--surface);\n }\n .asset-card-2.prod-lib-card .prod-action[hidden] { display: none; }\n .asset-card-2.prod-lib-card .prod-action .btn-aigen {\n width: 100%;\n display: inline-flex; align-items: center; justify-content: center; gap: 6px;\n height: 34px; padding: 0 14px;\n background: var(--heat);\n color: var(--accent-white);\n border: 1px solid var(--heat);\n border-radius: var(--r-sm);\n font-size: 13px; font-weight: 500;\n cursor: pointer;\n font-family: inherit;\n box-shadow:\n inset 0 -2px 4px rgba(250, 93, 25, 0.20),\n 0 1px 1px rgba(250, 93, 25, 0.12),\n 0 2px 4px rgba(250, 93, 25, 0.10);\n transition: background var(--t-base), box-shadow var(--t-base), transform var(--t-base);\n }\n .asset-card-2.prod-lib-card .prod-action .btn-aigen:hover {\n background: #FB6E2E;\n box-shadow:\n inset 0 -2px 4px rgba(250, 93, 25, 0.24),\n 0 2px 4px rgba(250, 93, 25, 0.20),\n 0 4px 12px rgba(250, 93, 25, 0.18);\n transform: translateY(-1px);\n }\n .asset-card-2.prod-lib-card .prod-action .btn-aigen:active { transform: translateY(0); }\n .asset-card-2.prod-lib-card .prod-action .btn-aigen:disabled {\n opacity: .65; cursor: not-allowed; transform: none;\n box-shadow: inset 0 -2px 4px rgba(250, 93, 25, 0.20);\n }\n .asset-card-2.prod-lib-card .prod-action .btn-aigen .ai-spark {\n width: 14px; height: 14px;\n flex-shrink: 0;\n }\n\n /* 通用资产详情 modal · 参考布局 v2 */\n .asset-modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1010; display: none; align-items: center; justify-content: center; padding: 40px; }\n .asset-modal-bg.show { display: flex; }\n .asset-modal { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); width: min(1040px, 100%); max-height: calc(100vh - 80px); overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 16px 48px rgba(0,0,0,.18); }\n .asset-modal-h { display: flex; align-items: center; gap: 10px; padding: 14px 20px; border-bottom: 1px solid var(--border-faint); }\n .asset-modal-h h2 { font-size: 15px; font-weight: 600; }\n .asset-modal-h .ad-tag { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .asset-modal-h .x { width: 30px; height: 30px; display: grid; place-items: center; background: transparent; border: 0; cursor: pointer; color: var(--black-alpha-56); border-radius: var(--r-sm); margin-left: auto; }\n .asset-modal-h .x:hover { background: var(--black-alpha-8); color: var(--accent-black); }\n .asset-modal-body { padding: 20px 24px 24px; overflow-y: auto; flex: 1; }\n .asset-detail-grid { display: grid; grid-template-columns: 340px 1fr; gap: 24px; }\n /* 左栏:大立绘 + 缩略 */\n .asset-detail-lead { display: flex; flex-direction: column; gap: 10px; }\n .asset-detail-lead .ad-lead-wrap { position: relative; }\n .asset-detail-lead .placeholder.ad-lead-img { aspect-ratio: 3/4; border-radius: var(--r-md); }\n /* 查看大图 icon · 悬浮容器才显示 · 32×32 icon-only */\n .ad-zoom-btn {\n position: absolute; right: 8px; bottom: 8px;\n width: 32px; height: 32px; padding: 0;\n background: rgba(21,20,15,.7); color: #fff;\n border: 0; border-radius: var(--r-sm);\n display: grid; place-items: center;\n cursor: pointer; backdrop-filter: blur(4px);\n opacity: 0;\n transition: opacity var(--t-base), background var(--t-base);\n z-index: 3;\n }\n .ad-zoom-btn:hover { background: rgba(21,20,15,.92); }\n .ad-zoom-btn svg { width: 14px; height: 14px; }\n .asset-detail-lead .ad-lead-wrap:hover .ad-zoom-btn,\n .asset-detail-tri-row .placeholder:hover .ad-zoom-btn { opacity: 1; }\n .asset-detail-tri-row .placeholder { position: relative; }\n .asset-detail-lead .ad-thumbs {\n display: flex; gap: 8px;\n }\n .asset-detail-lead .ad-thumbs .thumb {\n flex: 0 0 64px;\n aspect-ratio: 3/4;\n border-radius: var(--r-sm);\n border: 1px solid var(--border-faint);\n cursor: pointer; overflow: hidden;\n transition: border-color var(--t-base);\n }\n .asset-detail-lead .ad-thumbs .thumb:hover { border-color: var(--heat-40); }\n .asset-detail-lead .ad-thumbs .thumb.active { border-color: var(--heat); border-width: 2px; }\n\n /* 右栏 section 通用 */\n .asset-detail-right .ad-section + .ad-section { margin-top: 18px; }\n .asset-detail-section-h {\n display: flex; align-items: center; gap: 8px;\n font-size: 13px; font-weight: 600; color: var(--accent-black);\n margin-bottom: 10px;\n }\n .asset-detail-section-h .ic {\n width: 14px; height: 14px;\n color: var(--heat); flex-shrink: 0;\n display: grid; place-items: center;\n }\n .asset-detail-section-h .ic svg { width: 14px; height: 14px; }\n .asset-detail-section-h .ad-ratio-chip {\n margin-left: auto;\n font-family: var(--font-mono); font-size: 10.5px;\n padding: 2px 8px; border-radius: var(--r-sm);\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n color: var(--black-alpha-56); letter-spacing: .02em;\n }\n .asset-detail-section-h .ad-icon-btn {\n width: 28px; height: 28px;\n display: grid; place-items: center;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--black-alpha-56); cursor: pointer;\n transition: background var(--t-base), color var(--t-base), border-color var(--t-base);\n }\n .asset-detail-section-h .ad-icon-btn:hover { color: var(--heat); border-color: var(--heat-40); }\n .asset-detail-section-h .ad-icon-btn svg { width: 12px; height: 12px; }\n\n /* 三视图 — 始终单张 16:9 大图 (不分 3 张) */\n .asset-detail-tri-row { margin-top: 0; }\n .asset-detail-tri-row .placeholder { aspect-ratio: 16 / 9; border-radius: var(--r-md); }\n /* 三视图 · 用户上传 历史版本 strip */\n .md-view-versions { display: flex; gap: 6px; overflow-x: auto; padding: 2px; }\n .md-view-versions .v-thumb { flex: 0 0 auto; width: 72px; aspect-ratio: 16/9; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm); position: relative; cursor: pointer; display: grid; place-items: center; overflow: hidden; transition: border-color var(--t-base); }\n .md-view-versions .v-thumb:hover { border-color: var(--heat-40); }\n .md-view-versions .v-thumb.active { border-color: var(--heat); border-width: 2px; box-shadow: 0 0 0 2px var(--heat-12); }\n .md-view-versions .v-thumb .v { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-56); letter-spacing: .02em; }\n .md-view-versions .v-thumb.active .v { color: var(--heat); font-weight: 600; }\n .asset-detail-tri-row .placeholder.missing { display: grid; place-items: center; border: 1px dashed var(--border-faint); background: var(--background-lighter); color: var(--black-alpha-48); font-family: var(--font-mono); font-size: 12px; padding: 12px; text-align: center; cursor: pointer; transition: border-color var(--t-base), color var(--t-base); gap: 8px; }\n .asset-detail-tri-row .placeholder.missing:hover { border-color: var(--heat); color: var(--heat); }\n\n /* 简介文字 + 标签 */\n .ad-intro {\n font-size: 13px; line-height: 1.65;\n color: var(--black-alpha-72);\n margin: 0 0 12px;\n }\n .ad-tags {\n display: flex; flex-wrap: wrap; gap: 8px;\n }\n .ad-tags .ad-tag-chip {\n height: 26px; padding: 0 12px;\n display: inline-flex; align-items: center;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n font-size: 12px; color: var(--accent-black);\n }\n .ad-tags .ad-tag-add {\n width: 26px; height: 26px;\n display: grid; place-items: center;\n background: var(--background-lighter);\n border: 1px dashed var(--black-alpha-24);\n border-radius: var(--r-sm);\n color: var(--black-alpha-56); cursor: pointer;\n transition: border-color var(--t-base), color var(--t-base);\n }\n .ad-tags .ad-tag-add:hover { border-color: var(--heat); color: var(--heat); }\n .ad-tags .ad-tag-add svg { width: 12px; height: 12px; }\n\n /* 属性表 · 3 列 × N 行 */\n .ad-props {\n margin-top: 18px;\n display: grid;\n grid-template-columns: repeat(3, 1fr);\n column-gap: 24px;\n row-gap: 0;\n border-top: 1px solid var(--border-faint);\n padding-top: 16px;\n }\n .ad-props .ad-prop {\n display: flex; align-items: baseline;\n padding: 10px 0;\n border-bottom: 1px solid var(--border-faint);\n font-size: 12.5px;\n min-height: 38px;\n }\n .ad-props .ad-prop:nth-last-child(-n+3) { border-bottom: 0; }\n .ad-props .ad-prop .k {\n flex: 0 0 64px;\n color: var(--black-alpha-56);\n font-family: var(--font-mono); font-size: 11px;\n }\n .ad-props .ad-prop .v {\n color: var(--accent-black);\n font-weight: 500;\n word-break: break-all;\n }\n\n .asset-detail-tip { margin-top: 10px; padding: 10px 12px; background: var(--heat-12); border: 1px solid var(--heat-20); border-radius: var(--r-sm); font-size: 12px; color: var(--accent-black); display: flex; align-items: center; gap: 8px; line-height: 1.5; }\n .asset-detail-tip svg { width: 14px; height: 14px; color: var(--heat); flex-shrink: 0; }\n .asset-detail-tip .ai-gen-btn { margin-left: auto; height: 26px; padding: 0 10px; background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: var(--r-sm); font-size: 11.5px; cursor: pointer; font-family: inherit; flex-shrink: 0; }\n\n /* footer · 左侧统计 + 右侧按钮 */\n .asset-modal-f { padding: 14px 20px; border-top: 1px solid var(--border-faint); display: flex; align-items: center; gap: 8px; }\n .asset-modal-f .ad-foot-stats { display: flex; gap: 6px; margin-right: auto; }\n .asset-modal-f .ad-stat-btn {\n height: 32px; padding: 0 12px;\n display: inline-flex; align-items: center; gap: 6px;\n background: transparent;\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--black-alpha-72);\n font-size: 12.5px; font-family: inherit;\n cursor: pointer;\n transition: background var(--t-base), color var(--t-base), border-color var(--t-base);\n }\n .asset-modal-f .ad-stat-btn:hover { background: var(--background-lighter); color: var(--accent-black); border-color: var(--black-alpha-24); }\n .asset-modal-f .ad-stat-btn svg { width: 13px; height: 13px; }\n .asset-modal-f .ad-stat-btn b { color: var(--accent-black); font-weight: 600; }\n\n /* 演员库 / 场景库 全屏弹窗(沿用 model-photo .ml-modal 结构) */\n .ml-modal-bg { position: fixed; inset: 0; background: var(--surface); z-index: 1000; display: none; }\n .ml-modal-bg.show { display: flex; }\n .ml-modal { margin: 0; flex: 1; background: var(--surface); border-radius: 0; overflow: hidden; display: flex; flex-direction: column; }\n .ml-modal-h { display: flex; align-items: center; padding: 14px 28px; border-bottom: 1px solid var(--border-faint); flex-shrink: 0; }\n .ml-modal-h h2 { font-size: 16px; font-weight: 600; }\n .ml-modal-h .ct { margin-left: 10px; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .ml-modal-h .x { margin-left: auto; width: 32px; height: 32px; display: grid; place-items: center; background: transparent; border: 0; border-radius: var(--r-sm); cursor: pointer; color: var(--black-alpha-56); transition: background var(--t-base), color var(--t-base); }\n .ml-modal-h .x:hover { background: var(--black-alpha-8); color: var(--accent-black); }\n .ml-modal-h .x svg { width: 16px; height: 16px; }\n .ml-modal-body { flex: 1; min-height: 0; display: grid; grid-template-columns: 200px 1fr; }\n .ml-side { border-right: 1px solid var(--border-faint); padding: 18px 0; overflow-y: auto; }\n .ml-side .ml-side-h { padding: 0 20px 8px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; }\n .ml-side .ml-side-item { display: flex; align-items: center; gap: 8px; padding: 9px 20px; cursor: pointer; color: var(--black-alpha-72); font-size: 13px; border-left: 3px solid transparent; transition: background var(--t-base), color var(--t-base); }\n .ml-side .ml-side-item:hover { background: var(--black-alpha-4); }\n .ml-side .ml-side-item.active { background: var(--heat-12); color: var(--accent-black); border-left-color: var(--heat); font-weight: 600; }\n .ml-side .ml-side-item .ct { margin-left: auto; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); }\n .ml-main { overflow-y: auto; padding: 0; display: flex; flex-direction: column; min-width: 0; position: relative; }\n .ml-toolbar { padding: 14px 28px; border-bottom: 1px solid var(--border-faint); display: flex; align-items: center; gap: 18px; flex-shrink: 0; flex-wrap: wrap; }\n .ml-toolbar .btn-up { height: 32px; padding: 0 14px; display: inline-flex; align-items: center; gap: 6px; background: var(--surface); border: 1px solid var(--black-alpha-12); border-radius: var(--r-sm); color: var(--accent-black); font-family: inherit; font-size: 12.5px; cursor: pointer; transition: background var(--t-base), border-color var(--t-base); }\n .ml-toolbar .btn-up:hover { background: var(--background-lighter); border-color: var(--black-alpha-24); }\n .ml-toolbar .btn-up svg { width: 14px; height: 14px; }\n .ml-toolbar .chip-group { display: inline-flex; align-items: center; gap: 6px; }\n .ml-toolbar .chip-group .lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; margin-right: 4px; }\n .ml-toolbar .chip { height: 26px; padding: 0 12px; border-radius: 999px; background: transparent; border: 1px solid var(--black-alpha-12); color: var(--black-alpha-72); font-size: 12px; cursor: pointer; font-family: inherit; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }\n .ml-toolbar .chip:hover { color: var(--accent-black); }\n .ml-toolbar .chip.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat-40); font-weight: 600; }\n .ml-scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 20px 28px 28px; }\n /* 卡片 · 视觉对齐 model-photo .model-card (padding 8 / gap 6 / 无 foot 行) */\n .ml-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 12px; }\n .ml-card {\n position: relative;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n padding: 8px;\n cursor: pointer;\n display: flex; flex-direction: column; gap: 6px;\n transition: background var(--t-base), border-color var(--t-base);\n }\n .ml-card:hover { background: var(--surface); }\n .ml-card .placeholder { aspect-ratio: 3/4; border-radius: var(--r-sm); }\n .ml-card .ml-card-nm { font-size: 12.5px; font-weight: 500; color: var(--accent-black); }\n .ml-card .ml-card-sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }\n\n /* 「添加演员/场景」入口卡 · 与 model-photo 模特库视觉一致 */\n .ml-card.ml-upload-card { border: 1.5px dashed var(--black-alpha-24); background: var(--surface); display: flex; flex-direction: column; gap: 8px; transition: border-color var(--t-base), background var(--t-base); }\n .ml-card.ml-upload-card:hover { border-color: var(--heat); background: var(--heat-12); box-shadow: none; }\n .ml-card.ml-upload-card:focus-visible { outline: 2px solid var(--heat); outline-offset: 2px; }\n .ml-card.ml-upload-card .up-thumb { aspect-ratio: 3/4; border-radius: var(--r-sm); background: transparent; display: grid; place-items: center; }\n .ml-card.ml-upload-card .up-plus { width: 44px; height: 44px; border-radius: 50%; background: var(--surface); border: 1px solid var(--black-alpha-12); color: var(--black-alpha-56); display: grid; place-items: center; transition: background var(--t-base), color var(--t-base), border-color var(--t-base), transform var(--t-base); }\n .ml-card.ml-upload-card:hover .up-plus { background: var(--heat); border-color: var(--heat); color: var(--accent-white); transform: scale(1.06); }\n .ml-card.ml-upload-card .up-plus svg { width: 22px; height: 22px; }\n .ml-card.ml-upload-card .ml-card-nm { color: var(--accent-black); }\n .ml-card.ml-upload-card:hover .ml-card-nm { color: var(--heat); }\n\n /* ════════ 添加演员 / 场景 · 工作台画布 ════════ */\n .ml-canvas {\n position: absolute; inset: 0; z-index: 10;\n background: var(--background-base);\n display: flex; flex-direction: column;\n opacity: 0; visibility: hidden;\n transform: scale(.94); transform-origin: 32px 80px;\n transition: opacity .28s ease, transform .32s cubic-bezier(.18,.72,.28,1), visibility .32s;\n pointer-events: none;\n }\n .ml-canvas.show { opacity: 1; visibility: visible; transform: scale(1); pointer-events: auto; }\n .ml-canvas-h { display: flex; align-items: center; gap: 12px; padding: 14px 28px; border-bottom: 1px solid var(--border-faint); flex-shrink: 0; background: var(--surface); }\n .ml-canvas-h .back-btn { display: inline-flex; align-items: center; gap: 4px; height: 28px; padding: 0 10px; background: transparent; border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-72); font-family: inherit; font-size: 12px; cursor: pointer; transition: border-color var(--t-base), color var(--t-base), background var(--t-base); }\n .ml-canvas-h .back-btn:hover { border-color: var(--heat-40); color: var(--heat); background: var(--heat-12); }\n .ml-canvas-h .back-btn svg { width: 12px; height: 12px; }\n .ml-canvas-h h3 { font-size: 15px; font-weight: 600; color: var(--accent-black); margin: 0; }\n .ml-canvas-h .mono { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }\n\n .ml-canvas-body { flex: 1; min-height: 0; display: grid; grid-template-columns: 2fr 1fr; overflow: hidden; }\n .mc-ai { position: relative; display: flex; flex-direction: column; min-height: 0; background: var(--background-base); }\n .mc-up { position: relative; display: flex; flex-direction: column; min-height: 0; background: var(--surface); border-left: 1px solid var(--border-faint); }\n\n .mc-stream { flex: 1; min-height: 0; overflow-y: auto; padding: 28px 28px 220px; background: var(--background-base); }\n .mc-stream-inner { width: 100%; margin: 0 auto; display: flex; flex-direction: column; gap: 28px; }\n .mc-empty { flex: 1; min-height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; padding: 40px; color: var(--black-alpha-56); text-align: center; }\n .mc-empty .badge { font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em; color: var(--black-alpha-48); text-transform: uppercase; }\n .mc-empty h2 { font-size: 22px; font-weight: 600; color: var(--accent-black); letter-spacing: -.015em; margin: 0; }\n .mc-empty p { font-size: 13px; max-width: 460px; line-height: 1.6; margin: 0; }\n .mc-empty .ic { width: 64px; height: 64px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); display: grid; place-items: center; color: var(--heat); }\n .mc-empty .ic svg { width: 28px; height: 28px; }\n .mc-empty .examples { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; max-width: 720px; }\n .mc-empty .examples .ex { padding: 6px 12px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-pill); font-size: 12px; color: var(--black-alpha-72); cursor: pointer; transition: border-color var(--t-base), color var(--t-base), background var(--t-base); }\n .mc-empty .examples .ex:hover { border-color: var(--heat-20); color: var(--heat); background: var(--heat-12); }\n\n .mc-msg { display: flex; flex-direction: column; gap: 14px; }\n .mc-msg-prompt { display: flex; align-items: flex-start; gap: 12px; }\n .mc-msg-prompt .quote { flex-shrink: 0; width: 28px; height: 28px; border-radius: var(--r-sm); background: var(--surface); border: 1px solid var(--border-faint); color: var(--heat); display: grid; place-items: center; }\n .mc-msg-prompt .quote svg { width: 13px; height: 13px; }\n .mc-msg-prompt .pt { flex: 1; min-width: 0; padding-top: 4px; }\n .mc-msg-prompt .pt-text { font-size: 14px; color: var(--accent-black); line-height: 1.55; word-break: break-word; }\n .mc-msg-prompt .pt-tags { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; align-items: center; }\n .mc-msg-prompt .pt-tags .meta-chip { padding: 2px 8px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); }\n .mc-msg-prompt .pt-tags .sep { color: var(--black-alpha-24); }\n .mc-msg-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }\n @media (max-width: 1280px) { .mc-msg-grid { grid-template-columns: repeat(3, 1fr); } }\n .mc-cell { position: relative; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); overflow: hidden; aspect-ratio: 3/4; cursor: pointer; }\n .mc-cell.selected { border-color: var(--heat); box-shadow: 0 0 0 2px var(--heat-12); }\n .mc-cell .ph-frame { position: absolute; inset: 0; display: grid; place-items: center; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-32); letter-spacing: .02em; background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px); }\n .mc-cell.gen .ph-frame { animation: mc-pulse 1.4s ease-in-out infinite; }\n @keyframes mc-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .55; } }\n .mc-cell:hover { border-color: var(--black-alpha-32); }\n .mc-cell .pick-badge { position: absolute; top: 6px; left: 6px; background: var(--heat); color: var(--accent-white); padding: 2px 7px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .04em; display: none; }\n .mc-cell.selected .pick-badge { display: block; }\n .mc-cell .cell-ops { position: absolute; top: 6px; right: 6px; display: flex; gap: 4px; opacity: 0; transition: opacity var(--t-base); z-index: 2; }\n .mc-cell:hover .cell-ops { opacity: 1; }\n .mc-cell .cell-ops button { width: 26px; height: 26px; background: rgba(255,255,255,.92); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--accent-black); cursor: pointer; display: grid; place-items: center; backdrop-filter: blur(4px); transition: border-color var(--t-base), color var(--t-base); }\n .mc-cell .cell-ops button:hover { border-color: var(--heat); color: var(--heat); }\n .mc-cell .cell-ops button svg { width: 12px; height: 12px; }\n\n .mc-msg-ops { display: flex; gap: 8px; }\n .mc-msg-ops button { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); font-size: 12.5px; color: var(--accent-black); font-family: inherit; cursor: pointer; transition: border-color var(--t-base), color var(--t-base), background var(--t-base); }\n .mc-msg-ops button:hover { border-color: var(--heat-20); color: var(--heat); background: var(--heat-12); }\n .mc-msg-ops button svg { width: 13px; height: 13px; }\n\n .mc-input-wrap { position: absolute; left: 0; right: 0; bottom: 0; padding: 14px 28px 22px; background: linear-gradient(to bottom, transparent 0, var(--background-base) 24px); z-index: 5; }\n .mc-input { max-width: 720px; margin: 0 auto; background: var(--surface); border: 1px solid var(--border-faint); border-radius: 18px; padding: 12px 14px 10px; display: flex; flex-direction: column; gap: 8px; box-shadow: 0 6px 24px rgba(0,0,0,.06); transition: border-color var(--t-base); }\n .mc-input:focus-within { border-color: var(--heat-40); }\n .mc-input-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }\n .mc-input-top .add-btn { flex-shrink: 0; width: 64px; height: 64px; background: var(--background-lighter); border: 1px dashed var(--border-faint); border-radius: var(--r-md); display: grid; place-items: center; color: var(--black-alpha-56); cursor: pointer; transition: border-color var(--t-base), color var(--t-base), background var(--t-base); }\n .mc-input-top .add-btn:hover { border-color: var(--heat-40); color: var(--heat); background: var(--heat-12); }\n .mc-input-top .add-btn svg { width: 22px; height: 22px; }\n .mc-input-refs { display: contents; }\n .mc-input-ref { position: relative; width: 64px; height: 64px; border-radius: var(--r-md); overflow: hidden; background: var(--background-lighter); border: 1px solid var(--border-faint); flex-shrink: 0; }\n .mc-input-ref img { width: 100%; height: 100%; object-fit: cover; }\n .mc-input-ref .x { position: absolute; top: 3px; right: 3px; width: 18px; height: 18px; background: rgba(0,0,0,.7); color: var(--accent-white); border: 0; border-radius: 50%; display: grid; place-items: center; cursor: pointer; }\n .mc-input-ref .x svg { width: 10px; height: 10px; }\n .mc-input textarea#mc-input-text { width: 100%; border: 0; outline: 0; resize: none; background: transparent; font-family: inherit; font-size: 14px; line-height: 1.5; color: var(--accent-black); min-height: 44px; max-height: 220px; padding: 4px 2px; }\n .mc-input textarea#mc-input-text::placeholder { color: var(--black-alpha-48); }\n .mc-input-bottom { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }\n .mc-input-bottom .param { position: relative; display: inline-flex; align-items: center; gap: 4px; height: 26px; padding: 0 9px; background: var(--background-lighter); border: 1px solid transparent; border-radius: var(--r-pill); font-size: 11.5px; color: var(--black-alpha-72); cursor: pointer; transition: border-color var(--t-base), color var(--t-base), background var(--t-base); }\n .mc-input-bottom .param:hover { background: var(--surface); border-color: var(--border-faint); }\n .mc-input-bottom .param .lbl-mono { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; margin-right: 1px; }\n .mc-input-bottom .param svg { width: 10px; height: 10px; opacity: .6; }\n .mc-input-bottom .right-meta { margin-left: auto; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .mc-input-bottom .right-meta .val { color: var(--accent-black); }\n .mc-input .send-btn { flex-shrink: 0; width: 32px; height: 32px; background: var(--heat); color: var(--accent-white); border: 0; border-radius: var(--r-md); cursor: pointer; display: grid; place-items: center; transition: opacity var(--t-base), filter var(--t-base); margin-left: 8px; }\n .mc-input .send-btn:hover { filter: brightness(1.05); }\n .mc-input .send-btn:disabled { opacity: .4; cursor: not-allowed; }\n .mc-input .send-btn svg { width: 15px; height: 15px; }\n\n .mc-up-tabs { display: flex; border-bottom: 1px solid var(--border-faint); flex-shrink: 0; background: var(--surface); }\n .mc-up-tab { flex: 1; height: 44px; background: transparent; border: 0; border-bottom: 2px solid transparent; font-family: inherit; font-size: 13px; font-weight: 500; color: var(--black-alpha-56); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }\n .mc-up-tab:hover { color: var(--accent-black); background: var(--background-lighter); }\n .mc-up-tab.active { color: var(--heat); border-bottom-color: var(--heat); font-weight: 600; background: var(--surface); }\n .mc-up-body { flex: 1; min-height: 0; padding: 18px 20px 14px; display: flex; flex-direction: column; gap: 18px; overflow-y: auto; }\n .mc-up-section { display: flex; flex-direction: column; gap: 8px; }\n .mc-up-sec-h { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }\n .mc-up-name { width: 100%; height: 36px; padding: 0 12px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); font-family: inherit; font-size: 13.5px; color: var(--accent-black); outline: none; transition: border-color var(--t-base), background var(--t-base); }\n .mc-up-name:focus { border-color: var(--heat-40); background: var(--surface); }\n .mc-up-name::placeholder { color: var(--black-alpha-40); }\n\n .mc-portrait-ai .empty { aspect-ratio: 3/4; max-height: 220px; border: 1.5px dashed var(--black-alpha-24); border-radius: var(--r-md); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; background: var(--background-lighter); text-align: center; padding: 14px; }\n .mc-portrait-ai .empty[hidden] { display: none; }\n .mc-portrait-ai .picked[hidden] { display: none; }\n .mc-portrait-ai .empty .ic { width: 38px; height: 38px; border-radius: 50%; background: var(--surface); border: 1px solid var(--border-faint); display: grid; place-items: center; color: var(--black-alpha-48); }\n .mc-portrait-ai .empty .ic svg { width: 16px; height: 16px; }\n .mc-portrait-ai .empty .desc { font-size: 12.5px; color: var(--black-alpha-72); line-height: 1.55; }\n .mc-portrait-ai .empty .mono { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; }\n .mc-portrait-ai .picked { position: relative; aspect-ratio: 3/4; max-height: 280px; background: var(--background-lighter); border: 1.5px solid var(--heat); border-radius: var(--r-md); overflow: hidden; }\n .mc-portrait-ai .picked .ph-frame { position: absolute; inset: 0; display: grid; place-items: center; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-32); letter-spacing: .02em; background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px); }\n .mc-portrait-ai .picked .ops { position: absolute; top: 8px; right: 8px; display: flex; gap: 4px; }\n .mc-portrait-ai .picked .ops button { width: 26px; height: 26px; background: rgba(255,255,255,.92); border: 1px solid var(--border-faint); border-radius: var(--r-sm); display: grid; place-items: center; color: var(--accent-black); cursor: pointer; transition: border-color var(--t-base), color var(--t-base); }\n .mc-portrait-ai .picked .ops button:hover { border-color: var(--heat); color: var(--heat); }\n .mc-portrait-ai .picked .ops button svg { width: 12px; height: 12px; }\n .mc-portrait-ai .picked .badge { position: absolute; top: 8px; left: 8px; background: var(--heat); color: var(--accent-white); padding: 2px 7px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .04em; }\n\n .mc-portrait-local .drop { border: 1.5px dashed var(--black-alpha-24); border-radius: var(--r-md); padding: 20px 14px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; cursor: pointer; background: var(--background-lighter); transition: border-color var(--t-base), background var(--t-base); text-align: center; }\n .mc-portrait-local .drop:hover, .mc-portrait-local .drop.dragover { border-color: var(--heat); background: var(--heat-12); }\n .mc-portrait-local .drop .ic { width: 32px; height: 32px; background: var(--heat); color: var(--accent-white); border-radius: 50%; display: grid; place-items: center; }\n .mc-portrait-local .drop .ic svg { width: 14px; height: 14px; }\n .mc-portrait-local .drop .t { font-size: 12.5px; font-weight: 600; color: var(--accent-black); }\n .mc-portrait-local .drop .d { font-size: 11px; color: var(--black-alpha-48); }\n .mc-portrait-local .list-h { display: flex; align-items: center; gap: 4px; margin-top: 6px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; }\n .mc-portrait-local .list-h .ct { color: var(--accent-black); font-weight: 600; }\n .mc-portrait-local .list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }\n .mc-portrait-local .list:empty { display: none; }\n .mc-portrait-local .thumb { position: relative; aspect-ratio: 3/4; border-radius: var(--r-sm); overflow: hidden; background: var(--background-lighter); border: 1px solid var(--border-faint); }\n .mc-portrait-local .thumb img { width: 100%; height: 100%; object-fit: cover; }\n .mc-portrait-local .thumb .x { position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; background: rgba(0,0,0,.7); color: var(--accent-white); border: 0; border-radius: 50%; display: grid; place-items: center; cursor: pointer; }\n .mc-portrait-local .thumb .x svg { width: 10px; height: 10px; }\n\n .mc-up[data-kind=\"scene\"] .mc-triview { display: none; }\n .mc-triview .result-wrap { display: flex; flex-direction: column; gap: 8px; }\n .mc-triview .result { position: relative; aspect-ratio: 16/9; background: var(--background-lighter); border: 1.5px solid var(--border-faint); border-radius: var(--r-md); overflow: hidden; transition: border-color var(--t-base); }\n .mc-triview.has-result .result { border-color: var(--heat); }\n .mc-triview .result .ph-frame { position: absolute; inset: 0; display: grid; place-items: center; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-32); letter-spacing: .02em; background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px); pointer-events: none; }\n .mc-triview .result.gen .ph-frame { animation: mc-pulse 1.4s ease-in-out infinite; }\n .mc-triview .overlay-gen-btn { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 2; display: inline-flex; align-items: center; gap: 6px; height: 36px; padding: 0 18px; background: var(--heat); color: var(--accent-white); border: 0; border-radius: var(--r-pill); font-family: inherit; font-size: 13px; font-weight: 500; cursor: pointer; box-shadow: 0 4px 12px rgba(250, 93, 25, .28); transition: filter var(--t-base), opacity var(--t-base), transform var(--t-base), box-shadow var(--t-base); }\n .mc-triview .overlay-gen-btn:hover:not(:disabled) { filter: brightness(1.06); transform: translate(-50%, -50%) scale(1.03); box-shadow: 0 6px 16px rgba(250, 93, 25, .36); }\n .mc-triview .overlay-gen-btn:disabled { background: var(--black-alpha-24); color: var(--surface); cursor: not-allowed; box-shadow: none; }\n .mc-triview .overlay-gen-btn svg { width: 13px; height: 13px; }\n .mc-triview .overlay-hint { position: absolute; left: 0; right: 0; bottom: 10px; text-align: center; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; pointer-events: none; z-index: 2; }\n .mc-triview .result-ops { display: flex; gap: 6px; align-items: center; }\n .mc-triview .result-ops .cost { margin-left: auto; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .mc-triview .result-ops button { display: inline-flex; align-items: center; gap: 5px; height: 28px; padding: 0 10px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); font-family: inherit; font-size: 11.5px; color: var(--accent-black); cursor: pointer; transition: border-color var(--t-base), color var(--t-base); }\n .mc-triview .result-ops button:hover { border-color: var(--heat); color: var(--heat); }\n .mc-triview .result-ops button svg { width: 11px; height: 11px; }\n .mc-triview .history { display: flex; flex-direction: column; gap: 6px; }\n .mc-triview .history .h-lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }\n .mc-triview .history .h-lbl .ct { color: var(--accent-black); font-weight: 600; }\n .mc-triview .history .h-row { display: flex; gap: 6px; overflow-x: auto; padding: 2px; }\n .mc-triview .history .h-thumb { flex: 0 0 auto; width: 72px; aspect-ratio: 16/9; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm); position: relative; cursor: pointer; display: grid; place-items: center; overflow: hidden; transition: border-color var(--t-base); }\n .mc-triview .history .h-thumb:hover { border-color: var(--heat-40); }\n .mc-triview .history .h-thumb.active { border-color: var(--heat); border-width: 2px; box-shadow: 0 0 0 2px var(--heat-12); }\n .mc-triview .history .h-thumb .v { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-56); letter-spacing: .02em; }\n .mc-triview .history .h-thumb.active .v { color: var(--heat); font-weight: 600; }\n .mc-triview .history .h-thumb .badge { position: absolute; top: 2px; left: 2px; font-family: var(--font-mono); font-size: 8.5px; padding: 0 4px; line-height: 12px; background: var(--heat); color: var(--accent-white); border-radius: 2px; letter-spacing: .02em; display: none; }\n .mc-triview .history .h-thumb.active .badge { display: block; }\n\n .mc-up-foot { padding: 12px 20px 16px; border-top: 1px solid var(--border-faint); display: flex; align-items: center; gap: 10px; flex-shrink: 0; flex-wrap: wrap; }\n .mc-up-foot .stat { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n .mc-up-foot .stat b { color: var(--accent-black); font-weight: 600; }\n .mc-up-foot .stat.ok { color: var(--heat); }\n .mc-up-foot .commit-btn { display: inline-flex; align-items: center; gap: 5px; height: 32px; padding: 0 14px; background: var(--heat); color: var(--accent-white); border: 0; border-radius: var(--r-sm); font-family: inherit; font-size: 12.5px; cursor: pointer; transition: filter var(--t-base), opacity var(--t-base); }\n .mc-up-foot .commit-btn:hover { filter: brightness(1.05); }\n .mc-up-foot .commit-btn:disabled { opacity: .4; cursor: not-allowed; filter: none; }\n .mc-up-foot .commit-btn svg { width: 12px; height: 12px; }\n\n /* 离开工作台 · 二次确认 */\n .mc-leave-bg { position: fixed; inset: 0; background: rgba(21,20,15,.42); backdrop-filter: blur(8px); z-index: 1300; display: none; align-items: center; justify-content: center; padding: 40px; }\n .mc-leave-bg.show { display: flex; }\n .mc-leave { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); width: 420px; max-width: 100%; box-shadow: 0 16px 48px rgba(0,0,0,.18); overflow: hidden; }\n .mc-leave .lv-h { display: flex; align-items: center; gap: 10px; padding: 14px 20px 10px; }\n .mc-leave .lv-h .ic { width: 28px; height: 28px; display: grid; place-items: center; border-radius: var(--r-sm); background: var(--crimson-bg); color: var(--accent-crimson); flex-shrink: 0; }\n .mc-leave .lv-h .ic svg { width: 16px; height: 16px; }\n .mc-leave .lv-h h3 { font-size: 15px; font-weight: 600; color: var(--accent-black); margin: 0; }\n .mc-leave .lv-h .mono { margin-left: auto; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; }\n .mc-leave .lv-b { padding: 4px 20px 18px; font-size: 13px; line-height: 1.65; color: var(--black-alpha-72); }\n .mc-leave .lv-b b { color: var(--accent-black); font-weight: 600; }\n .mc-leave .lv-f { display: flex; align-items: center; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--border-faint); background: var(--background-lighter); }\n .mc-leave .lv-f .spacer { flex: 1; }\n .mc-leave .lv-f .btn { height: 34px; padding: 0 14px; font-size: 13px; }\n .mc-leave .btn-danger { background: var(--accent-crimson); color: var(--accent-white); border-color: var(--accent-crimson); font-weight: 600; }\n .mc-leave .btn-danger:hover { background: var(--accent-crimson); border-color: var(--accent-crimson); filter: brightness(.95); }\n\n /* ─── 添加来源 · 选择 modal (AI 生成 / 本地上传) ─── */\n .ml-up-choice-bg { position: fixed; inset: 0; z-index: 1200; background: rgba(21, 20, 15, .42); display: none; place-items: center; padding: 16px; }\n .ml-up-choice-bg.show { display: grid; }\n .ml-up-choice { width: min(560px, 92vw); background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); box-shadow: 0 16px 48px rgba(21, 20, 15, .18); overflow: hidden; position: relative; }\n .ml-up-choice .uc-h { display: flex; align-items: center; gap: 12px; padding: 18px 22px 14px; border-bottom: 1px solid var(--border-faint); }\n .ml-up-choice .uc-h .ic-m { width: 36px; height: 36px; border-radius: var(--r-md); background: var(--heat-12); color: var(--heat); display: grid; place-items: center; flex-shrink: 0; }\n .ml-up-choice .uc-h .ic-m svg { width: 18px; height: 18px; }\n .ml-up-choice .uc-h .ti { display: flex; flex-direction: column; gap: 3px; min-width: 0; }\n .ml-up-choice .uc-h .ti strong { font-size: 15px; color: var(--accent-black); font-weight: 600; }\n .ml-up-choice .uc-h .ti .mono { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .ml-up-choice .uc-h .uc-x { margin-left: auto; width: 28px; height: 28px; background: transparent; border: 0; border-radius: var(--r-sm); color: var(--black-alpha-56); cursor: pointer; display: grid; place-items: center; }\n .ml-up-choice .uc-h .uc-x:hover { background: var(--background-lighter); color: var(--accent-black); }\n .ml-up-choice .uc-h .uc-x svg { width: 14px; height: 14px; }\n .ml-up-choice .uc-body { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 20px 22px 22px; }\n .ml-up-choice .uc-option { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 18px 16px; text-align: left; cursor: pointer; font-family: inherit; display: flex; flex-direction: column; gap: 10px; transition: border-color var(--t-base), background var(--t-base); }\n .ml-up-choice .uc-option:hover { border-color: var(--heat); background: var(--heat-12); }\n .ml-up-choice .uc-option .opt-ic { width: 40px; height: 40px; border-radius: var(--r-md); background: var(--background-lighter); color: var(--heat); border: 1px solid var(--heat-20); display: grid; place-items: center; transition: background var(--t-base), color var(--t-base); }\n .ml-up-choice .uc-option:hover .opt-ic { background: var(--heat); color: var(--accent-white); border-color: var(--heat); }\n .ml-up-choice .uc-option .opt-ic svg { width: 18px; height: 18px; }\n .ml-up-choice .uc-option .opt-t { font-size: 14px; font-weight: 600; color: var(--accent-black); }\n .ml-up-choice .uc-option .opt-d { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-56); letter-spacing: .02em; line-height: 1.55; }\n .ml-up-choice .uc-option .opt-tag { margin-top: auto; align-self: flex-start; font-family: var(--font-mono); font-size: 10.5px; padding: 2px 8px; border-radius: var(--r-sm); background: var(--background-lighter); color: var(--black-alpha-72); letter-spacing: .04em; }\n .ml-up-choice .uc-option:hover .opt-tag { background: var(--surface); color: var(--heat); }\n\n /* 新增人物 modal · 立绘 + 三视图 上传区 */\n .upload-zone { aspect-ratio: 3/4; background: var(--background-lighter); border: 1px dashed var(--border-faint); border-radius: var(--r-md); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; cursor: pointer; transition: border-color var(--t-base), background var(--t-base); padding: 16px; text-align: center; color: var(--black-alpha-56); font-size: 12px; }\n .upload-zone:hover { border-color: var(--heat); background: var(--heat-12); color: var(--heat); }\n .upload-zone.lead { aspect-ratio: 3/4; }\n .upload-zone svg { width: 20px; height: 20px; }\n .upload-zone-tri { aspect-ratio: 1; padding: 8px; font-size: 10.5px; }\n .prompt-box { background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 10px 12px; font-size: 12px; color: var(--black-alpha-56); margin-top: 8px; line-height: 1.55; font-family: var(--font-mono); letter-spacing: .01em; transition: border-color var(--t-base), background var(--t-base); }\n .prompt-box[contenteditable=\"true\"] { cursor: text; outline: none; }\n .prompt-box[contenteditable=\"true\"]:hover { border-color: var(--heat-20); }\n .prompt-box[contenteditable=\"true\"]:focus { border-color: var(--heat); background: var(--surface); color: var(--accent-black); box-shadow: 0 0 0 3px var(--heat-12); }\n .fail-icon { width: 28px; height: 28px; background: var(--accent-crimson); color: var(--accent-white); display: grid; place-items: center; font-weight: 700; font-size: 16px; border-radius: 50%; }\n\n /* === STAGE 3 · 故事板(略缩图竖向侧栏 + 主图区)=== */\n .stage-storyboard { display: grid; grid-template-columns: minmax(0, 1fr) 380px; gap: 16px; align-items: stretch; }\n .sb-canvas { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 14px; display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: 14px; }\n .sb-scenes-col { display: flex; flex-direction: column; gap: 10px; overflow-y: auto; overflow-x: hidden; max-height: 560px; padding-right: 6px; scrollbar-width: thin; }\n .sb-scenes-col::-webkit-scrollbar { width: 6px; }\n .sb-scenes-col::-webkit-scrollbar-thumb { background: var(--border-faint); border-radius: 4px; }\n .sb-scene-thumb { flex: 0 0 auto; cursor: pointer; display: flex; flex-direction: column; gap: 6px; padding: 6px; border: 1px solid var(--border-faint); border-radius: var(--r-md); background: var(--surface); transition: border-color var(--t-base), background var(--t-base); }\n .sb-scene-thumb:hover { background: var(--background-lighter); }\n .sb-scene-thumb.selected { border-color: var(--heat); background: var(--heat-12); }\n .sb-scene-thumb .placeholder { aspect-ratio: 1; }\n .sb-scene-thumb .nm { font-size: 11.5px; font-weight: 500; color: var(--accent-black); }\n .sb-scene-thumb .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); }\n .sb-main-img { aspect-ratio: 16/9; min-height: 0; }\n\n .sb-rerun-note {\n display: flex;\n align-items: flex-start;\n gap: 10px;\n padding: 10px 12px;\n margin-bottom: 14px;\n background: rgba(180,83,9,.08);\n border: 1px solid rgba(180,83,9,.20);\n border-radius: var(--r-md);\n color: #7C3A05;\n line-height: 1.55;\n }\n .sb-rerun-note .warn-ic {\n width: 22px;\n height: 22px;\n border-radius: var(--r-sm);\n background: rgba(180,83,9,.12);\n color: #B45309;\n display: grid;\n place-items: center;\n flex: 0 0 22px;\n }\n .sb-rerun-note .warn-ic svg {\n width: 14px;\n height: 14px;\n }\n .sb-rerun-note .note-copy {\n min-width: 0;\n font-size: 11.5px;\n }\n .sb-rerun-note strong { color: #B45309; }\n .sb-rerun-note a { color: #B45309; text-decoration: underline; text-underline-offset: 2px; }\n\n .sb-stage-actions { display: flex; gap: 8px; margin-top: 14px; margin-bottom: 12px; }\n\n /* 故事板历史版本 */\n .sb-history { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-faint); }\n .sb-history-h { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; margin-bottom: 10px; }\n .sb-history-row { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: thin; }\n .sb-history-row::-webkit-scrollbar { height: 6px; }\n .sb-history-row::-webkit-scrollbar-thumb { background: var(--border-faint); }\n .sb-history-thumb { flex: 0 0 80px; min-width: 80px; display: flex; flex-direction: column; gap: 4px; padding: 4px; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); cursor: pointer; transition: border-color var(--t-base); }\n .sb-history-thumb:hover { border-color: var(--heat); }\n .sb-history-thumb.current { border-color: var(--heat); background: var(--heat-12); }\n .sb-history-thumb .placeholder { aspect-ratio: 1; }\n .sb-history-thumb .ts { font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-48); text-align: center; }\n .sb-history-thumb.current .ts { color: var(--heat); font-weight: 600; }\n\n .pill-cta { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 14px; border-radius: 999px; font-size: 12.5px; cursor: pointer; font-family: inherit; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }\n .pill-cta.heat { background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); }\n .pill-cta.heat:hover { box-shadow: var(--shadow-cta-hover); }\n .pill-cta.ghost { background: var(--surface); color: var(--accent-black); border: 1px solid var(--border-faint); }\n .pill-cta.ghost:hover { background: var(--background-lighter); border-color: var(--heat-20); color: var(--heat); }\n .pill-cta svg { width: 13px; height: 13px; }\n\n /* === STAGE 3 / 4 跳过条 === */\n .skip-row { display: flex; justify-content: flex-end; margin-bottom: 12px; }\n\n .sb-side .pane { padding: 18px; }\n .prompt-edit { background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 12px 14px; font-family: var(--font-mono); font-size: 11.5px; line-height: 1.7; color: var(--accent-black); white-space: pre-wrap; min-height: 200px; outline: none; letter-spacing: .01em; cursor: text; transition: border-color var(--t-base), background var(--t-base), box-shadow var(--t-base); }\n .prompt-edit:hover { border-color: var(--heat-20); }\n .prompt-edit:focus { border-color: var(--heat); background: var(--surface); box-shadow: 0 0 0 3px var(--heat-12); }\n .asset-tag { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-pill); font-size: 11.5px; }\n .asset-tag .dotc { width: 14px; height: 14px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: 50%; }\n\n /* === STAGE 4 · 视频片段 === */\n .queue-bar { display: flex; align-items: center; gap: 16px; padding: 14px 18px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); margin-bottom: 18px; }\n .queue-bar .bar-wrap { flex: 1; height: 6px; background: var(--background-lighter); overflow: hidden; }\n .queue-bar .bar-wrap > span { display: block; height: 100%; background: var(--heat); }\n\n .video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(196px, 216px)); gap: 14px; align-content: start; align-items: start; justify-content: start; }\n .video-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: border-color var(--t-base), background var(--t-base); overflow: hidden; display: flex; flex-direction: column; min-height: 0; }\n .video-card:hover { border-color: var(--heat-40); background: var(--background-lighter); }\n .video-thumb { width: 100%; aspect-ratio: 9/16; position: relative; border-radius: var(--r-md) var(--r-md) 0 0; overflow: hidden; }\n .video-thumb .play { position: absolute; inset: 0; display: grid; place-items: center; background: rgba(0,0,0,0.05); cursor: pointer; opacity: 0; transition: opacity .15s; }\n .video-thumb:hover .play { opacity: 1; }\n .video-thumb .btn-play { width: 36px; height: 36px; background: rgba(0,0,0,.7); color: var(--accent-white); border-radius: 50%; display: grid; place-items: center; }\n .video-card .body { padding: 12px 12px 14px; flex: 1 1 auto; min-height: 118px; display: flex; flex-direction: column; }\n .video-card-head { display: flex; align-items: flex-start; gap: 8px; }\n .video-card-title { min-width: 0; flex: 1 1 auto; font-size: 13px; line-height: 1.4; font-weight: 600; color: var(--accent-black); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .video-card-head .pill { flex: 0 0 auto; }\n .video-meta { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; margin-top: 5px; }\n .video-actions { margin-top: auto; padding-top: 12px; display: flex; align-items: center; gap: 10px; }\n\n /* 视频详情 modal 大视频 + 历史版本 */\n .vd-main-wrap { display: flex; gap: 18px; align-items: flex-start; }\n .vd-main { flex: 0 0 280px; aspect-ratio: 9/16; max-height: 460px; }\n .vd-main .placeholder { aspect-ratio: 9/16; height: 100%; }\n .vd-info { flex: 1; min-width: 0; }\n .vd-prompt-field { margin-top: 16px; }\n .vd-prompt-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 6px; }\n .vd-prompt-head .label { font-family: var(--font-mono); font-size: 11px; font-weight: 500; color: var(--black-alpha-56); letter-spacing: .04em; }\n .vd-prompt-head .hint { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .vd-prompt-edit { min-height: 120px; }\n .vd-prompt-edit:empty::before { content: attr(data-placeholder); color: var(--black-alpha-40); }\n .vd-history { margin-top: 16px; }\n .vd-history-h { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; margin-bottom: 10px; display: flex; align-items: center; gap: 6px; }\n .vd-history-row { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: thin; }\n .vd-history-thumb { flex: 0 0 64px; min-width: 64px; display: flex; flex-direction: column; gap: 4px; padding: 4px; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); cursor: pointer; transition: border-color var(--t-base); position: relative; }\n .vd-history-thumb:hover { border-color: var(--heat); }\n .vd-history-thumb.current { border-color: var(--heat); background: var(--heat-12); }\n .vd-history-thumb.adopted::after { content: ''; position: absolute; top: 2px; right: 2px; width: 14px; height: 14px; background: var(--heat); border-radius: 50%; background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E\"); background-repeat: no-repeat; background-position: center; background-size: 9px 9px; }\n .vd-history-thumb .placeholder { aspect-ratio: 9/16; }\n .vd-history-thumb .ts { font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-48); text-align: center; }\n .vd-modal-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; }\n .vd-modal-actions .pill-cta { min-width: 96px; justify-content: center; }\n\n /* === STAGE 5 · 编辑器 === */\n .editor { display: grid; grid-template-columns: 1fr 280px; grid-template-rows: 1fr auto; gap: 0; height: 580px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); }\n .editor-preview { padding: 16px; border-right: 1px solid var(--border-faint); border-bottom: 1px solid var(--border-faint); display: flex; flex-direction: column; gap: 12px; }\n .editor-preview .canvas { flex: 1 1 0; min-height: 0; aspect-ratio: 9/16; margin: 0 auto; background:\n repeating-linear-gradient(135deg, rgba(0,0,0,0.03) 0 1px, transparent 1px 12px),\n var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n display: grid; place-items: center;\n color: var(--black-alpha-48);\n font-family: var(--font-mono);\n font-size: 12px; }\n .editor-preview .controls { display: flex; align-items: center; gap: 8px; justify-content: center; }\n .ctl-btn { width: 36px; height: 36px; border: 1px solid var(--border-faint); background: var(--surface); color: var(--black-alpha-56); border-radius: var(--r-md); display: grid; place-items: center; cursor: pointer; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }\n .ctl-btn:hover { color: var(--heat); border-color: var(--heat-40); background: var(--heat-12); }\n\n .editor-props { padding: 16px; border-bottom: 1px solid var(--border-faint); overflow-y: auto; }\n .props-tabs { display: flex; gap: 0; margin-bottom: 14px; border-bottom: 1px solid var(--border-faint); }\n .props-tabs > div { padding: 8px 12px; font-size: 12.5px; color: var(--black-alpha-56); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }\n .props-tabs > div.active { color: var(--heat); border-bottom-color: var(--heat); font-weight: 600; }\n .style-swatch { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }\n .swatch-card { padding: 10px; border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; }\n .swatch-card:hover { background: var(--background-lighter); }\n .swatch-card.selected { border-color: var(--heat); background: var(--heat-12); }\n .swatch-card .demo { font-size: 12px; padding: 6px 8px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); margin-bottom: 4px; text-align: center; }\n .swatch-card .demo.b { background: var(--accent-black); color: var(--accent-white); font-family: serif; }\n .swatch-card .demo.c { color: var(--heat); -webkit-text-stroke: 0.5px var(--accent-black); }\n .swatch-card .demo.d { background: var(--accent-honey); color: var(--accent-black); font-weight: 700; }\n .swatch-card .nm { font-size: 11px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; }\n .props-row { display: flex; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border-faint); font-size: 12.5px; }\n .props-row:last-child { border-bottom: 0; }\n .props-row .k { color: var(--black-alpha-48); flex: 1; font-family: var(--font-mono); font-size: 11px; letter-spacing: .02em; }\n .input-mini { width: 90px; padding: 0 10px; height: 28px; font-size: 12px; border-radius: var(--r-md); background: var(--surface); border: 1px solid var(--black-alpha-12); }\n\n /* ── 时间轴 · 剪映风格(Restraint 浅色规范) ── */\n .timeline { position: relative; grid-column: 1 / -1; padding: 14px 16px; background: var(--background-base); }\n\n /* 工具栏 */\n .tl-toolbar { display: flex; align-items: center; gap: 4px; margin-bottom: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--border-faint); }\n .tl-toolbar .tl-action { display: inline-flex; align-items: center; gap: 5px; height: 28px; padding: 0 10px; background: transparent; border: 1px solid transparent; border-radius: var(--r-sm); color: var(--black-alpha-72); font-size: 12px; font-family: inherit; cursor: pointer; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }\n .tl-toolbar .tl-action:hover { background: var(--surface); border-color: var(--border-faint); color: var(--accent-black); }\n .tl-toolbar .tl-action.danger:hover { color: var(--accent-crimson); border-color: var(--accent-crimson); }\n .tl-toolbar .tl-action svg { width: 13px; height: 13px; }\n .tl-toolbar .tl-sep { width: 1px; height: 16px; background: var(--border-faint); margin: 0 4px; }\n .tl-toolbar .tl-zoom { display: inline-flex; align-items: center; gap: 8px; }\n .tl-toolbar .tl-zoom .lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; }\n .tl-toolbar .tl-zoom input[type=\"range\"] { width: 120px; accent-color: var(--heat); }\n\n /* 时间刻度 · 主/次刻度 */\n .tl-ruler { display: grid; grid-template-columns: 80px 1fr; align-items: end; padding: 0; margin-bottom: 4px; }\n .tl-ruler .l { font-family: var(--font-mono); color: var(--black-alpha-48); padding: 0 4px 4px; font-size: 10.5px; letter-spacing: .04em; align-self: end; }\n .tl-ruler .rule-track { position: relative; height: 22px; border-bottom: 1px solid var(--border-faint); }\n .tl-ruler .rule-track .tick { position: absolute; bottom: 0; width: 1px; background: var(--black-alpha-24); }\n .tl-ruler .rule-track .tick.major { height: 8px; background: var(--black-alpha-48); }\n .tl-ruler .rule-track .tick.minor { height: 4px; }\n .tl-ruler .rule-track .t { position: absolute; bottom: 10px; transform: translateX(-50%); font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-56); letter-spacing: .02em; white-space: nowrap; }\n\n /* 轨道行 */\n .tl-track { display: grid; grid-template-columns: 80px 1fr; align-items: center; padding: 3px 0; }\n .tl-track .label { display: flex; align-items: center; gap: 6px; padding-left: 4px; font-size: 11.5px; color: var(--black-alpha-72); font-weight: 500; }\n .tl-track .label .ico { width: 18px; height: 18px; display: grid; place-items: center; border-radius: var(--r-sm); flex-shrink: 0; }\n .tl-track .label .ico svg { width: 12px; height: 12px; }\n .tl-track .label.video .ico { background: var(--heat-12); color: var(--heat); }\n .tl-track .label.subtitle .ico { background: var(--forest-bg); color: var(--accent-forest); }\n .tl-track .label.bgm .ico { background: rgba(144, 97, 255, .10); color: var(--accent-amethyst); }\n\n /* 轨道 lane · 绝对定位容器 + 1s 网格线 */\n .tl-track .lane {\n position: relative;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n }\n .tl-track.video-track .lane { height: 46px; }\n .tl-track.subtitle-track .lane { height: 28px; }\n .tl-track.bgm-track .lane { height: 34px; }\n .tl-track .lane::before {\n content: \"\"; position: absolute; inset: 0;\n background-image: repeating-linear-gradient(to right,\n var(--border-faint) 0, var(--border-faint) 1px,\n transparent 1px, transparent calc(100% / 15));\n pointer-events: none; opacity: .55;\n border-radius: inherit;\n }\n .tl-track .lane.is-snapping .clip,\n .tl-track .lane.is-reordering .clip:not(.dragging) { transition: left .16s ease, width .16s ease; }\n .tl-align-guide {\n position: absolute;\n width: 1.5px;\n background: var(--accent-forest);\n opacity: 0;\n pointer-events: none;\n z-index: 9;\n }\n .tl-align-guide.show { opacity: 1; }\n .tl-insert-ghost {\n position: absolute; top: 3px; bottom: 3px;\n border: 1px dashed var(--heat);\n background: var(--heat-12);\n border-radius: 4px;\n pointer-events: none;\n box-sizing: border-box;\n z-index: 2;\n }\n\n /* 片段公共 · 绝对定位 · left/width 由 data 驱动 */\n .clip {\n position: absolute; top: 3px; bottom: 3px;\n display: flex; align-items: center; gap: 6px;\n padding: 0 8px; font-size: 11px;\n border: 1px solid transparent;\n border-radius: 4px;\n cursor: grab; overflow: hidden; white-space: nowrap; user-select: none;\n box-sizing: border-box;\n }\n .clip:hover { filter: brightness(1.04); }\n .clip .num { font-family: var(--font-mono); font-weight: 700; opacity: .85; flex-shrink: 0; }\n .clip .lbl { overflow: hidden; text-overflow: ellipsis; }\n\n /* 视频片段 · 内嵌胶卷帧条(预览缩略) */\n .clip.video {\n background: var(--heat-12); border-color: var(--heat-40); color: var(--heat);\n }\n .clip.video .frames {\n position: absolute; top: 0; bottom: 0; left: 0;\n width: var(--src-width, 100%);\n transform: translateX(var(--src-offset, 0%));\n display: flex; gap: 0;\n pointer-events: none; z-index: 0;\n border-radius: inherit;\n overflow: hidden;\n }\n .clip.video .frames .fr {\n flex: 1; min-width: 0;\n background:\n repeating-linear-gradient(45deg,\n transparent 0, transparent 4px,\n rgba(38,38,38,.06) 4px, rgba(38,38,38,.06) 5px),\n rgba(250,93,25,.10);\n }\n .clip.video .frames .fr + .fr { border-left: 1px solid rgba(255,255,255,.55); }\n .clip.video:hover .frames .fr { background-color: rgba(250,93,25,.18); }\n .clip.video .num, .clip.video .lbl { position: relative; z-index: 1; }\n .clip.video.selected {\n background: var(--heat); color: var(--accent-white); border-color: var(--heat);\n box-shadow: var(--shadow-cta);\n z-index: 3;\n }\n .clip.video.selected .frames .fr {\n background:\n repeating-linear-gradient(45deg,\n transparent 0, transparent 4px,\n rgba(255,255,255,.22) 4px, rgba(255,255,255,.22) 5px),\n rgba(255,255,255,.06);\n }\n .clip.video.selected .frames .fr + .fr { border-left-color: rgba(255,255,255,.28); }\n\n /* 字幕片段 · 薄条 + 引号符号 */\n .clip.subtitle {\n background: var(--forest-bg); border-color: var(--forest-bd); color: var(--accent-forest);\n font-size: 11px;\n }\n .clip.subtitle .lbl::before {\n content: \"“\"; font-family: serif; font-size: 14px; opacity: .55; margin-right: 2px;\n }\n .clip.subtitle:hover { background: rgba(31, 138, 81, .14); }\n .clip.subtitle.selected {\n background: var(--accent-forest); color: var(--accent-white); border-color: var(--accent-forest);\n box-shadow: 0 2px 6px rgba(31, 138, 81, .35);\n z-index: 3;\n }\n .clip.subtitle.selected .lbl::before { opacity: .8; }\n\n /* BGM 片段 · 内嵌波形 */\n .clip.bgm {\n background: rgba(144,97,255,.10); border-color: rgba(144,97,255,.30);\n color: var(--accent-amethyst);\n }\n .clip.bgm .wave {\n position: absolute; inset: 6px 10px; pointer-events: none;\n opacity: .5; display: block; z-index: 0;\n }\n .clip.bgm .wave svg { width: 100%; height: 100%; display: block; }\n .clip.bgm:hover { background: rgba(144,97,255,.16); }\n .clip.bgm.selected {\n background: var(--accent-amethyst); color: var(--accent-white); border-color: var(--accent-amethyst);\n box-shadow: 0 2px 6px rgba(144, 97, 255, .35);\n z-index: 3;\n }\n .clip.bgm.selected .wave { opacity: .75; }\n .clip.bgm.selected .wave svg rect { fill: rgba(255,255,255,.9); }\n .clip.bgm .lbl, .clip.bgm .num { position: relative; z-index: 1; }\n\n /* 通用 trim 把手 · 三轨皆可剪 */\n .clip.selected .trim-l,\n .clip.selected .trim-r {\n position: absolute; top: 3px; bottom: 3px; width: 4px;\n background: rgba(255,255,255,.92); border-radius: 2px;\n z-index: 5;\n cursor: ew-resize; pointer-events: auto;\n }\n .clip.selected .trim-l { left: 2px; }\n .clip.selected .trim-r { right: 2px; }\n .clip.selected .trim-l::before,\n .clip.selected .trim-r::before {\n content: \"\"; position: absolute; top: 50%; left: 50%;\n transform: translate(-50%, -50%);\n width: 2px; height: 10px;\n background: rgba(38,38,38,.4); border-radius: 1px;\n }\n\n /* 拖拽时片段移动光标 */\n .clip { cursor: grab; }\n .clip.selected { cursor: grab; }\n .clip:active,\n .clip.selected:active { cursor: grabbing; }\n .clip.dragging { cursor: grabbing; opacity: .9; z-index: 7 !important; }\n\n /* Playhead · 顶到时间尺、贯穿三条轨 · 可拖拽 */\n .playhead {\n position: absolute; top: -90px; bottom: -44px;\n width: 18px; transform: translateX(-50%);\n background: transparent;\n z-index: 10;\n pointer-events: auto;\n cursor: ew-resize;\n touch-action: none;\n }\n .playhead::after {\n content: ''; position: absolute; top: 0; bottom: 0; left: 50%;\n transform: translateX(-50%);\n width: 1.5px; background: var(--heat);\n pointer-events: none;\n }\n .playhead::before {\n content: ''; position: absolute; top: -4px; left: 50%;\n transform: translateX(-50%) rotate(45deg);\n width: 10px; height: 10px; background: var(--heat);\n box-shadow: 0 0 0 1.5px var(--surface);\n border-radius: 1px;\n pointer-events: none;\n }\n .playhead .ph-grab {\n position: absolute; top: -10px; left: 50%; transform: translateX(-50%);\n width: 24px; height: 24px;\n cursor: ew-resize; pointer-events: auto;\n border-radius: 50%;\n }\n .playhead.is-dragging::after { background: var(--heat); }\n .timeline.is-dragging-playhead { cursor: ew-resize; user-select: none; }\n\n /* Ruler 可点击 seek */\n .tl-ruler .rule-track { cursor: pointer; }\n .tl-ruler .rule-track:hover .tick.major { background: var(--heat-40); }\n\n /* Lane 也可点击 seek (空白处) */\n .tl-track .lane { cursor: pointer; }\n\n /* 播放/暂停按钮 active 态 · ctl-btn.is-playing 显示暂停 icon */\n .ctl-btn { transition: color var(--t-base); }\n .ctl-btn.is-playing { color: var(--heat); }\n\n /* 工具栏按钮 disabled */\n .tl-action:disabled { opacity: .4; cursor: not-allowed; }\n .tl-action:disabled:hover { background: transparent; border-color: transparent; color: var(--black-alpha-72); }\n</style>\n</head>\n<body>\n<div id=\"page\">\n\n<!-- Project header 移除 · 改为 topbar 中部 stage-pill(Shell.render 后注入)-->\n<!-- Stage stepper 移除 · 由 topbar 中部 5 个圆点替代,JS 仍读 .sp-dot[data-stage] 维持单一源 -->\n<div class=\"stage-pill-anchor\" id=\"stage-pill-anchor\" hidden>\n <a class=\"sp-dot\" data-stage=\"1\" href=\"#stage-1\"><span class=\"d\"></span><span class=\"l\">脚本</span></a>\n <span class=\"sp-line\"></span>\n <a class=\"sp-dot\" data-stage=\"2\" href=\"#stage-2\"><span class=\"d\"></span><span class=\"l\">基础资产</span></a>\n <span class=\"sp-line\"></span>\n <a class=\"sp-dot\" data-stage=\"3\" href=\"#stage-3\"><span class=\"d\"></span><span class=\"l\">故事板</span></a>\n <span class=\"sp-line\"></span>\n <a class=\"sp-dot\" data-stage=\"4\" href=\"#stage-4\"><span class=\"d\"></span><span class=\"l\">视频</span></a>\n <span class=\"sp-line\"></span>\n <a class=\"sp-dot\" data-stage=\"5\" href=\"#stage-5\"><span class=\"d\"></span><span class=\"l\">拼接导出</span></a>\n</div>\n\n<!-- ============= STAGE 1 · 脚本 ============= -->\n<section class=\"stage active\" data-stage-pane=\"1\">\n <div class=\"stage-script\">\n <div class=\"pane shot-list\">\n <div class=\"pane-h\">\n <div class=\"shot-headline\">\n <strong>镜头脚本</strong>\n <span class=\"muted-2 mono\" id=\"shots-meta\" style=\"font-size:11px;\">· 空 · 待生成</span>\n </div>\n <div class=\"script-brief-summary\" aria-label=\"当前创作方向\">\n <span class=\"pill neutral script-brief-pill\"><span class=\"k\">来源</span><span class=\"v\" id=\"brief-source\">未选择</span></span>\n <span class=\"pill neutral script-brief-pill\"><span class=\"k\">风格</span><span class=\"v\" id=\"brief-style\">待确认</span></span>\n <span class=\"pill neutral script-brief-pill\"><span class=\"k\">人物</span><span class=\"v\" id=\"brief-persona\">待确认</span></span>\n </div>\n <div class=\"script-tags\" id=\"script-tags\">\n <div class=\"tag-group\" data-kind=\"char\">\n <span class=\"tg-lbl\">// 人物</span>\n <button class=\"tag-add\" type=\"button\" aria-label=\"添加人物\">+</button>\n </div>\n <div class=\"tag-group\" data-kind=\"scene\">\n <span class=\"tg-lbl\">// 场景</span>\n <button class=\"tag-add\" type=\"button\" aria-label=\"添加场景\">+</button>\n </div>\n </div>\n <span class=\"spacer\"></span>\n <button class=\"btn btn-ghost btn-sm\" id=\"chat-regen-btn\">↻ 整体重写</button>\n </div>\n <div class=\"shots-body\" id=\"shots-body\">\n <!-- JS 注入空态/镜头卡片 -->\n </div>\n </div>\n\n <div class=\"stage-script-gutter\" id=\"stage-script-gutter\" role=\"separator\" aria-orientation=\"vertical\" aria-label=\"拖动调整脚本助手宽度\"></div>\n\n <div class=\"pane chat-pane\">\n <div class=\"pane-h\">\n <div class=\"ai-avatar\">AI</div>\n <strong>脚本助手</strong>\n <span class=\"muted-2 mono\" style=\"font-size:11px;\">· GPT-4o</span>\n <span class=\"spacer\"></span>\n <button class=\"btn btn-ghost btn-sm\" id=\"chat-clear-btn\">清空对话</button>\n </div>\n <div class=\"chat-body\" id=\"chat-body\">\n <!-- JS 注入空态/对话内容 -->\n </div>\n <div class=\"chat-input\">\n <div class=\"chat-input-card\">\n <div class=\"chat-attach-row\" id=\"chat-attach-row\" hidden></div>\n <textarea class=\"chat-input-area\" id=\"chat-textarea\" placeholder=\"直接说怎么改,如:更像小红书种草 / 换成熬夜党\" rows=\"2\"></textarea>\n <div class=\"chat-input-foot\">\n <button class=\"chat-icon-btn\" id=\"chat-upload-btn\" title=\"上传脚本附件\" aria-label=\"上传脚本附件\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 5v14M5 12h14\"/></svg>\n </button>\n <span class=\"spacer\"></span>\n <button class=\"chat-send-btn\" id=\"chat-send-btn\" title=\"发送\" aria-label=\"发送\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12h14M13 6l6 6-6 6\"/></svg>\n </button>\n </div>\n </div>\n <input type=\"file\" id=\"chat-upload-input\" hidden accept=\".txt,.md,.docx,.doc,.pdf,.srt,.json\">\n </div>\n </div>\n </div>\n\n <div class=\"stage-foot\">\n <div class=\"info\"><span class=\"mono\">[ LLM 用量 ~2.4k tokens · ¥0.04 · 失败不扣 · 通过后扣 ]</span></div>\n <div class=\"hstack\">\n <button class=\"btn\" onclick=\"Shell.toast('重新生成', 'POST /script/regen')\"><svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 12a8 8 0 0 1 14-5.5L21 9\"/><path d=\"M21 4v5h-5\"/><path d=\"M20 12a8 8 0 0 1-14 5.5L3 15\"/><path d=\"M3 20v-5h5\"/></svg> 重新生成全部</button>\n <button class=\"btn btn-primary btn-lg\" onclick=\"location.hash='#stage-2'\">确认脚本,进入下一步 <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12h14M12 5l7 7-7 7\"/></svg></button>\n </div>\n </div>\n</section>\n\n<!-- ============= STAGE 2 · 基础资产 ============= -->\n<section class=\"stage\" data-stage-pane=\"2\">\n <div class=\"stage-assets\">\n <div class=\"asset-side\">\n <div class=\"ttab active\" data-jump=\"asset-sec-products\"><span>商品</span><span class=\"num\">3 张</span></div>\n <div class=\"ttab\" data-jump=\"asset-sec-characters\"><span>人物</span><span class=\"num\">2/2</span></div>\n <div class=\"ttab\" data-jump=\"asset-sec-scenes\"><span>场景</span><span class=\"num\">3/3</span></div>\n <div class=\"info\">\n 基础资产是后续故事板的素材。所有卡片同时展示,点左侧分类直接定位。\n <br><br>\n <strong class=\"mono\">// 人物 +¥0.20/张</strong>\n <strong class=\"mono\">// 场景 +¥0.15/张</strong>\n <span style=\"color:var(--black-alpha-48);\">商品图无成本(直接复用商品库)</span>\n </div>\n </div>\n\n <div class=\"asset-main\">\n <!-- ===== 商品(项目内只有 1 个商品,从 URL ?product= 取)===== -->\n <section class=\"asset-sec\" id=\"asset-sec-products\">\n <div class=\"sec-h\">\n <h3>商品 · <span id=\"asset-prod-name\">透真补水面膜</span></h3>\n <span class=\"spacer\"></span>\n </div>\n <div class=\"prod-row\">\n <div class=\"asset-card-2 prod-lib-card\" data-asset-kind=\"product\" data-asset-id=\"prod-main\" id=\"asset-prod-card\">\n <div class=\"placeholder prod-thumb\">\n <span class=\"tri-missing-badge\" id=\"asset-prod-tri-badge\" tabindex=\"0\" role=\"button\" aria-label=\"缺三视图,查看说明\">\n <span class=\"ico\" aria-hidden=\"true\"></span>\n <span class=\"lbl-mono\">缺三视图</span>\n <span class=\"tri-missing-pop\" role=\"tooltip\">\n <span class=\"pop-h\">\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 9v4M12 17h.01\"/><path d=\"M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z\"/></svg>\n MISSING TRI-VIEW\n </span>\n <span class=\"pop-body\">该商品还未生成 <b>正 / 侧 / 背</b> 三视图。直接生成图片或视频,模型缺少多角度参考,角色一致性、姿态稳定性可能下降。</span>\n <span class=\"pop-tip\">建议:点右下 <b>AI 生成三视图</b> 先补齐三视图,再发起后续生成。</span>\n </span>\n </span>\n <span class=\"ph-frame\" id=\"asset-prod-thumb-label\">透真补水面膜 · 主图</span>\n </div>\n <div class=\"prod-body\">\n <div class=\"prod-name\" id=\"asset-prod-card-name\">透真补水面膜</div>\n <div class=\"prod-cat\">美妆个护</div>\n <div class=\"prod-date\">2026-05-15 创建</div>\n </div>\n <div class=\"prod-action\" id=\"asset-prod-action\">\n <button class=\"btn-aigen\" type=\"button\" data-stop id=\"asset-prod-aigen-btn\">\n <svg class=\"ai-spark\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3z\"/>\n <path d=\"M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7L19 14z\"/>\n </svg>\n AI 生成三视图\n </button>\n </div>\n </div>\n <div class=\"prod-preview\" id=\"asset-prod-preview\">\n <div class=\"prod-preview-h\">// 三视图预览 · <span id=\"prod-preview-status\">生成中</span></div>\n <div class=\"placeholder prod-preview-img\" id=\"prod-preview-img\"></div>\n <div class=\"prod-preview-foot\" id=\"prod-preview-foot\"></div>\n <div class=\"prod-preview-history\" id=\"prod-preview-history\">\n <div class=\"h-lbl\">// 历史版本 · <span class=\"ct\" id=\"prod-preview-history-count\">0</span> 版</div>\n <div class=\"h-row\" id=\"prod-preview-history-row\"></div>\n </div>\n </div>\n </div>\n </section>\n\n <!-- ===== 人物 ===== -->\n <section class=\"asset-sec\" id=\"asset-sec-characters\">\n <div class=\"sec-h\">\n <h3>人物 · 2 个</h3>\n <span class=\"spacer\"></span>\n <button class=\"btn btn-sm\" id=\"asset-add-character\" hidden>+ 新增人物</button>\n </div>\n\n <div class=\"asset-grid-2\">\n <div class=\"asset-card-2\" data-asset-kind=\"character\" data-asset-id=\"ch-linxi\">\n <div class=\"placeholder thumb-2\"><span class=\"ph-frame\">林夕 · 都市白领</span></div>\n <div class=\"body-2\">\n <div class=\"hstack\"><strong style=\"font-size:13.5px;\">主角 · 林夕</strong><span class=\"spacer\"></span></div>\n <div class=\"prompt-box\" contenteditable=\"true\" spellcheck=\"false\" data-stop>25-30 岁都市白领,长发,穿宽松米色家居服,温柔但带点疲倦感。</div>\n <div class=\"hstack\" style=\"margin-top:10px;\">\n <button class=\"btn btn-ghost btn-sm\" data-stop data-rerun>重跑</button>\n <span class=\"spacer\"></span>\n <button class=\"btn btn-ghost btn-sm\" data-stop data-replace>替换</button>\n </div>\n </div>\n </div>\n <div class=\"asset-card-2\" data-asset-kind=\"character\" data-asset-id=\"ch-anan\">\n <div class=\"placeholder thumb-2\">\n <div style=\"display:flex; flex-direction:column; gap:8px; align-items:center;\">\n <div class=\"spinner\"></div>\n <span class=\"ph-frame\">生成中 · 约 8s</span>\n </div>\n </div>\n <div class=\"body-2\">\n <div class=\"hstack\"><strong style=\"font-size:13.5px;\">朋友/同事 · 阿楠</strong><span class=\"spacer\"></span></div>\n <div class=\"prompt-box\" contenteditable=\"true\" spellcheck=\"false\" data-stop>25-30 岁同龄女性,短发,穿白色衬衫,妆容精致皮肤好,作为对比。</div>\n <div class=\"hstack\" style=\"margin-top:10px;\">\n <button class=\"btn btn-ghost btn-sm\" data-stop data-rerun disabled>重跑</button>\n <span class=\"spacer\"></span>\n <button class=\"btn btn-ghost btn-sm\" data-stop data-replace disabled>替换</button>\n </div>\n </div>\n </div>\n </div>\n\n </section>\n\n <!-- ===== 场景 ===== -->\n <section class=\"asset-sec\" id=\"asset-sec-scenes\">\n <div class=\"sec-h\">\n <h3>场景 · 3 个</h3>\n <span class=\"spacer\"></span>\n <button class=\"btn btn-sm\" id=\"asset-add-scene\" hidden>+ 新增场景</button>\n </div>\n\n <div class=\"asset-grid-2\">\n <div class=\"asset-card-2\" data-asset-kind=\"scene\" data-asset-id=\"sc-desk\">\n <div class=\"placeholder thumb-2\"><span class=\"ph-frame\">深夜办公桌</span></div>\n <div class=\"body-2\">\n <div class=\"hstack\"><strong style=\"font-size:13.5px;\">深夜办公桌</strong><span class=\"spacer\"></span></div>\n <div class=\"prompt-box\" contenteditable=\"true\" spellcheck=\"false\" data-stop>深夜居家办公环境,木质书桌,台灯暖光,电脑屏幕亮着。</div>\n <div class=\"hstack\" style=\"margin-top:10px;\">\n <button class=\"btn btn-ghost btn-sm\" data-stop data-rerun>重跑</button>\n <span class=\"spacer\"></span>\n <button class=\"btn btn-ghost btn-sm\" data-stop data-replace>替换</button>\n </div>\n </div>\n </div>\n <div class=\"asset-card-2\" data-asset-kind=\"scene\" data-asset-id=\"sc-bed\">\n <div class=\"placeholder thumb-2\"><span class=\"ph-frame\">床头特写</span></div>\n <div class=\"body-2\">\n <div class=\"hstack\"><strong style=\"font-size:13.5px;\">卧室床头</strong><span class=\"spacer\"></span></div>\n <div class=\"prompt-box\" contenteditable=\"true\" spellcheck=\"false\" data-stop>米白色床品,木质床头柜,闹钟显示晚间时间。</div>\n <div class=\"hstack\" style=\"margin-top:10px;\">\n <button class=\"btn btn-ghost btn-sm\" data-stop data-rerun>重跑</button>\n <span class=\"spacer\"></span>\n <button class=\"btn btn-ghost btn-sm\" data-stop data-replace>替换</button>\n </div>\n </div>\n </div>\n <div class=\"asset-card-2\" data-asset-kind=\"scene\" data-asset-id=\"sc-subway\">\n <div class=\"placeholder thumb-2\">\n <div style=\"display:flex; flex-direction:column; gap:6px; align-items:center;\">\n <div class=\"fail-icon\">!</div>\n <span class=\"ph-frame\">生成失败</span>\n </div>\n </div>\n <div class=\"body-2\">\n <div class=\"hstack\"><strong style=\"font-size:13.5px;\">通勤地铁</strong><span class=\"spacer\"></span><span class=\"pill err\"><span class=\"dot\"></span>失败</span></div>\n <div class=\"prompt-box\" contenteditable=\"true\" spellcheck=\"false\" data-stop>早高峰地铁车厢,光线偏冷,年轻通勤族,氛围紧张。</div>\n <div class=\"hstack\" style=\"margin-top:10px;\">\n <button class=\"btn btn-ghost btn-sm\" data-stop data-rerun>重跑</button>\n <span class=\"spacer\"></span>\n <button class=\"btn btn-ghost btn-sm\" data-stop data-replace>替换</button>\n </div>\n </div>\n </div>\n </div>\n\n </section>\n </div>\n </div>\n\n <div class=\"stage-foot\">\n <div class=\"info\"><span class=\"mono\">[ 已确认 ¥0.85 · 待生成 ¥0.20 · 失败 ¥0(不扣) ]</span></div>\n <div class=\"hstack\">\n <button class=\"btn\" onclick=\"location.hash='#stage-1'\"><svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19 12H5M12 19l-7-7 7-7\"/></svg> 返回脚本</button>\n <button class=\"btn btn-primary btn-lg\" onclick=\"location.hash='#stage-3'\">确认资产,进入故事板 <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12h14M12 5l7 7-7 7\"/></svg></button>\n </div>\n </div>\n</section>\n\n<!-- ============= STAGE 3 · 故事板(按场分) ============= -->\n<section class=\"stage\" data-stage-pane=\"3\">\n <div class=\"stage-storyboard\">\n <div class=\"sb-canvas\">\n <div class=\"sb-scenes-col\" id=\"sb-scenes-row\">\n <!-- JS 注入 略缩图 (竖向) -->\n </div>\n <div class=\"placeholder sb-main-img\" id=\"sb-main-img\"><span class=\"ph-frame\">未选择</span></div>\n </div>\n\n <div class=\"sb-side\">\n <div class=\"pane\" style=\"padding:18px;\">\n <div class=\"hstack\" style=\"margin-bottom:10px;\">\n <strong style=\"font-size:14px;\">故事板 · <span id=\"sb-side-scene\">场 1</span></strong>\n <span class=\"spacer\"></span>\n <span class=\"pill ok\"><span class=\"dot\"></span>已生成</span>\n </div>\n <div class=\"muted-2\" style=\"font-size:12px; line-height:1.55; margin-bottom:10px;\">\n 整张故事板由 image-2 一次性输出,包含画面 + 镜头说明。\n </div>\n <div class=\"sb-rerun-note\">\n <span class=\"warn-ic\" aria-hidden=\"true\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z\"/><path d=\"M12 9v4M12 17h.01\"/></svg>\n </span>\n <div class=\"note-copy\">\n <strong>仅支持整张重跑</strong> · 不能局部改某一镜。如需调单镜,先在 <a href=\"#stage-1\">Stage 1 脚本</a> 改镜头描述,再回此处整张重跑。\n </div>\n </div>\n\n <div class=\"muted mono\" style=\"font-size:11px; font-weight:500; margin-bottom:6px; letter-spacing:.04em;\">// 本场提示词</div>\n <div class=\"prompt-edit\" contenteditable=\"true\" id=\"sb-prompt-edit\"></div>\n\n <div class=\"sb-stage-actions\">\n <button class=\"pill-cta heat\" id=\"sb-rerun-btn\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 12a8 8 0 0 1 14-5.5L21 9\"/><path d=\"M21 4v5h-5\"/><path d=\"M20 12a8 8 0 0 1-14 5.5L3 15\"/><path d=\"M3 20v-5h5\"/></svg>\n 整张重跑\n </button>\n <span class=\"spacer\"></span>\n <span class=\"muted-2 mono\" style=\"font-size:11px; align-self: center;\">~¥0.45/场</span>\n </div>\n\n <div class=\"sb-history\">\n <div class=\"sb-history-h\">// 历史版本(<span id=\"sb-history-ct\">0</span>)</div>\n <div class=\"sb-history-row\" id=\"sb-history-row\">\n <div style=\"font-size: 11.5px; color: var(--black-alpha-48); padding: 12px 4px;\">// 暂无历史版本</div>\n </div>\n </div>\n\n <div class=\"divider\" style=\"margin-top: 16px;\"></div>\n\n <div class=\"muted mono\" style=\"font-size:11px; font-weight:500; margin-bottom:8px; letter-spacing:.04em;\">// 绑定的资产</div>\n <div style=\"display:flex; gap:6px; flex-wrap:wrap;\" id=\"sb-bound-assets\">\n <span class=\"asset-tag\"><span class=\"dotc\"></span>林夕(人物)</span>\n <span class=\"asset-tag\"><span class=\"dotc\"></span>深夜办公桌(场景)</span>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"stage-foot\">\n <div class=\"info\"><span class=\"mono\">[ image-2 单场 ¥0.45 · 累计 ¥1.35 · 整张重跑,失败不扣 ]</span></div>\n <div class=\"hstack\">\n <button class=\"btn\" onclick=\"location.hash='#stage-2'\"><svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19 12H5M12 19l-7-7 7-7\"/></svg> 返回资产</button>\n <button class=\"btn btn-primary btn-lg\" onclick=\"location.hash='#stage-4'\">确认故事板,开始生成视频 <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12h14M12 5l7 7-7 7\"/></svg></button>\n </div>\n </div>\n</section>\n\n<!-- ============= STAGE 4 · 视频(按场分,15s/场) ============= -->\n<section class=\"stage\" data-stage-pane=\"4\">\n <div class=\"queue-bar\">\n <div>\n <div style=\"font-size:14px; font-weight:600;\">视频生成 · 3 / 3 完成</div>\n <div class=\"muted-2 mono\" style=\"font-size:11px; margin-top:3px; letter-spacing:.02em;\">// 每场 Seedance 约 <span id=\"seedance-avg\">15</span> 秒 · 已完成所有场次</div>\n </div>\n <div class=\"bar-wrap\"><span style=\"width:100%\"></span></div>\n <span class=\"muted mono\" style=\"font-size:12px;\">100%</span>\n <button class=\"btn btn-sm\" onclick=\"Quota.preflight({stage:'Stage 4 视频片段 · 全部重跑', est: 1.35, force: true, demo:'block'})\">↻ 全部重跑</button>\n <input type=\"file\" id=\"stage4-upload-input\" accept=\"video/*\" multiple hidden>\n <button class=\"btn btn-sm\" type=\"button\" onclick=\"document.getElementById('stage4-upload-input').click()\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" style=\"margin-right:4px;\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"/><path d=\"M17 8l-5-5-5 5\"/><path d=\"M12 3v12\"/></svg>\n 上传视频\n </button>\n </div>\n\n <div class=\"video-grid\" id=\"video-grid\">\n <div class=\"video-card\" data-video-id=\"v1\" data-duration=\"15\">\n <div class=\"placeholder video-thumb\">\n <span class=\"ph-frame\">场 1 · 0-15s</span>\n <div class=\"play\"><div class=\"btn-play\"><svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\"><path d=\"M5 4l6 4-6 4z\" fill=\"currentColor\"/></svg></div></div>\n </div>\n <div class=\"body\">\n <div class=\"video-card-head\"><strong class=\"video-card-title\">场 1 · 深夜办公桌</strong><span class=\"pill ok\"><span class=\"dot\"></span>完成</span></div>\n <div class=\"video-meta\">15s · 1080×1920 · ¥0.45</div>\n <div class=\"video-actions\">\n <button class=\"btn btn-ghost btn-sm\" type=\"button\" data-vstop>重跑</button>\n <span class=\"spacer\"></span>\n <button class=\"btn btn-ghost btn-sm\" type=\"button\" data-vstop>下载</button>\n </div>\n </div>\n </div>\n <div class=\"video-card\" data-video-id=\"v2\" data-duration=\"12\">\n <div class=\"placeholder video-thumb\">\n <span class=\"ph-frame\">场 2 · 15-27s</span>\n <div class=\"play\"><div class=\"btn-play\"><svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\"><path d=\"M5 4l6 4-6 4z\" fill=\"currentColor\"/></svg></div></div>\n </div>\n <div class=\"body\">\n <div class=\"video-card-head\"><strong class=\"video-card-title\">场 2 · 面膜包装/特写</strong><span class=\"pill ok\"><span class=\"dot\"></span>完成</span></div>\n <div class=\"video-meta\">12s · 1080×1920 · ¥0.45</div>\n <div class=\"video-actions\">\n <button class=\"btn btn-ghost btn-sm\" type=\"button\" data-vstop>重跑</button>\n <span class=\"spacer\"></span>\n <button class=\"btn btn-ghost btn-sm\" type=\"button\" data-vstop>下载</button>\n </div>\n </div>\n </div>\n <div class=\"video-card\" data-video-id=\"v3\" data-duration=\"13\">\n <div class=\"placeholder video-thumb\">\n <span class=\"ph-frame\">场 3 · 27-40s</span>\n <div class=\"play\"><div class=\"btn-play\"><svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\"><path d=\"M5 4l6 4-6 4z\" fill=\"currentColor\"/></svg></div></div>\n </div>\n <div class=\"body\">\n <div class=\"video-card-head\"><strong class=\"video-card-title\">场 3 · 化妆台/产品定格</strong><span class=\"pill ok\"><span class=\"dot\"></span>完成</span></div>\n <div class=\"video-meta\">13s · 1080×1920 · ¥0.45</div>\n <div class=\"video-actions\">\n <button class=\"btn btn-ghost btn-sm\" type=\"button\" data-vstop>重跑</button>\n <span class=\"spacer\"></span>\n <button class=\"btn btn-ghost btn-sm\" type=\"button\" data-vstop>下载</button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"stage-foot\">\n <div class=\"info\"><span class=\"mono\">[ 已完成 3 场 · 累计 ¥1.35 · 总时长 <span id=\"seedance-total\">40</span>s · 失败不扣 · 通过后扣 ]</span></div>\n <div class=\"hstack\">\n <button class=\"btn\" onclick=\"location.hash='#stage-3'\"><svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19 12H5M12 19l-7-7 7-7\"/></svg> 返回故事板</button>\n <button class=\"btn btn-primary btn-lg\" onclick=\"location.hash='#stage-5'\">确认视频,进入拼接 <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12h14M12 5l7 7-7 7\"/></svg></button>\n </div>\n </div>\n</section>\n\n<!-- ===== Stage 4 · 视频详情 modal ===== -->\n<div class=\"asset-modal-bg\" id=\"video-detail-modal\">\n <div class=\"asset-modal\" style=\"width: min(880px, 100%);\">\n <div class=\"asset-modal-h\">\n <h2 id=\"vd-title\">视频详情</h2>\n <span style=\"font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48);\" id=\"vd-sub\">// 场 1 · 15s</span>\n <button class=\"x\" type=\"button\" aria-label=\"关闭\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6 6l12 12M6 18L18 6\"/></svg></button>\n </div>\n <div class=\"asset-modal-body\">\n <div class=\"vd-main-wrap\">\n <div class=\"vd-main\">\n <div class=\"placeholder\" id=\"vd-main-img\"><span class=\"ph-frame\">大视频预览</span></div>\n </div>\n <div class=\"vd-info\">\n <div class=\"asset-detail-section-h\">// 基础信息</div>\n <div class=\"asset-detail-info\" id=\"vd-info\"></div>\n\n <div class=\"vd-history\" style=\"margin-top:18px;\">\n <div class=\"vd-history-h\">// 历史版本 · <span id=\"vd-history-ct\">3</span> 版</div>\n <div class=\"vd-history-row\" id=\"vd-history-row\"></div>\n </div>\n\n <div class=\"vd-prompt-field\">\n <div class=\"vd-prompt-head\">\n <span class=\"label\">// 视频提示词</span>\n </div>\n <div class=\"prompt-edit vd-prompt-edit\" contenteditable=\"true\" role=\"textbox\" aria-label=\"视频提示词\" spellcheck=\"false\" id=\"vd-prompt-edit\" data-placeholder=\"// 输入本场 Seedance 提示词\"></div>\n </div>\n </div>\n </div>\n </div>\n <div class=\"asset-modal-f vd-modal-f\">\n <button class=\"btn btn-ghost\" type=\"button\" data-modal-close>关闭</button>\n <div class=\"vd-modal-actions\">\n <button class=\"pill-cta ghost\" type=\"button\" id=\"vd-regen-btn\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 12a9 9 0 1 0 3-6.7\"/><path d=\"M3 4v5h5\"/></svg>\n 重跑本场\n </button>\n <button class=\"pill-cta heat\" type=\"button\" id=\"vd-adopt-btn\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 12l5 5L20 6\"/></svg>\n 采用此版\n </button>\n </div>\n </div>\n </div>\n</div>\n\n<!-- ============= STAGE 5 · 拼接编辑器 ============= -->\n<section class=\"stage\" data-stage-pane=\"5\">\n <div class=\"editor\">\n <div class=\"editor-preview\">\n <div class=\"canvas\" id=\"ed-canvas\"><span id=\"ed-canvas-label\">9:16 预览 · 1080×1920</span></div>\n <div class=\"controls\">\n <button class=\"ctl-btn\" id=\"ed-prev-btn\" title=\"上一帧 (←)\"><svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\"><path d=\"M3 3v10l4-5zM9 3v10l4-5z\" fill=\"currentColor\"/></svg></button>\n <button class=\"ctl-btn\" id=\"ed-play-btn\" title=\"播放 / 暂停 (空格)\"><svg id=\"ed-play-icon\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\"><path d=\"M5 4l7 4-7 4z\" fill=\"currentColor\"/></svg></button>\n <button class=\"ctl-btn\" id=\"ed-next-btn\" title=\"下一帧 (→)\"><svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\"><path d=\"M13 3v10l-4-5zM7 3v10l-4-5z\" fill=\"currentColor\"/></svg></button>\n <span class=\"muted mono\" style=\"font-size:12px; margin-left:8px;\"><span id=\"ed-cur-time\">00:00.00</span> / <span id=\"ed-total-time\">00:15.00</span></span>\n </div>\n </div>\n\n <div class=\"editor-props\">\n <div class=\"props-tabs\">\n <div class=\"active\">字幕</div>\n <div>转场</div>\n <div>BGM</div>\n </div>\n <div class=\"muted mono\" style=\"font-size:11px; font-weight:500; margin-bottom:8px; letter-spacing:.04em;\">// 字幕样式</div>\n <div class=\"style-swatch\">\n <div class=\"swatch-card selected\"><div class=\"demo\">真实分享</div><div class=\"nm\">朴素白底</div></div>\n <div class=\"swatch-card\"><div class=\"demo b\">真实分享</div><div class=\"nm\">影视黑底</div></div>\n <div class=\"swatch-card\"><div class=\"demo c\">真实分享</div><div class=\"nm\">手写描边</div></div>\n <div class=\"swatch-card\"><div class=\"demo d\">真实分享</div><div class=\"nm\">综艺暖黄</div></div>\n </div>\n\n <div class=\"divider\"></div>\n\n <div class=\"muted mono\" style=\"font-size:11px; font-weight:500; margin-bottom:8px; letter-spacing:.04em;\">// 当前选中(<span id=\"ed-inspect-name\">未选</span>)</div>\n <div class=\"props-row\"><span class=\"k\">起始</span><input class=\"input-mini\" id=\"ed-inspect-start\" value=\"—\"></div>\n <div class=\"props-row\"><span class=\"k\">时长</span><input class=\"input-mini\" id=\"ed-inspect-dur\" value=\"—\"></div>\n <div class=\"props-row\"><span class=\"k\">音量</span><input class=\"input-mini\" value=\"100\"></div>\n <div class=\"props-row\"><span class=\"k\">速度</span><input class=\"input-mini\" value=\"1.0x\"></div>\n <div class=\"props-row\"><span class=\"k\">入场</span><span class=\"mono\" style=\"font-size:11.5px;\">交叉淡化</span></div>\n\n <div class=\"divider\"></div>\n\n <div class=\"muted mono\" style=\"font-size:11px; font-weight:500; margin-bottom:8px; letter-spacing:.04em;\">// BGM</div>\n <div class=\"props-row\" style=\"border-bottom:0;\">\n <span style=\"font-size:12px; flex:1;\">温柔治愈钢琴 · 0:42</span>\n <button class=\"btn btn-ghost btn-sm\">替换</button>\n </div>\n </div>\n\n <div class=\"timeline\" id=\"ed-timeline\">\n <div class=\"tl-toolbar\">\n <button class=\"tl-action\" id=\"ed-undo-btn\" title=\"撤销\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 7v6h6\"/><path d=\"M21 17a9 9 0 0 0-15-6.7L3 13\"/></svg>\n </button>\n <button class=\"tl-action\" id=\"ed-redo-btn\" title=\"重做\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 7v6h-6\"/><path d=\"M3 17a9 9 0 0 1 15-6.7L21 13\"/></svg>\n </button>\n <span class=\"tl-sep\"></span>\n <button class=\"tl-action\" id=\"ed-split-btn\" title=\"在播放头处分割选中片段\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"6\" cy=\"6\" r=\"3\"/><circle cx=\"6\" cy=\"18\" r=\"3\"/><path d=\"M20 4L8.12 15.88\"/><path d=\"M14.47 14.48L20 20\"/><path d=\"M8.12 8.12L12 12\"/></svg>\n 分割\n </button>\n <button class=\"tl-action\" id=\"ed-copy-btn\" title=\"复制选中片段\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\"/><path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"/></svg>\n 复制\n </button>\n <button class=\"tl-action danger\" id=\"ed-del-btn\" title=\"删除选中片段 (Delete)\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 0 1-2 1.8H8.5a2 2 0 0 1-2-1.8L5 6\"/></svg>\n 删除\n </button>\n <span class=\"spacer\"></span>\n <div class=\"tl-zoom\">\n <span class=\"lbl\">// zoom</span>\n <input type=\"range\" min=\"50\" max=\"200\" value=\"100\" id=\"ed-zoom-input\">\n </div>\n </div>\n\n <div class=\"tl-ruler\">\n <div class=\"l\">// time</div>\n <div class=\"rule-track\" id=\"ed-ruler\">\n <span class=\"tick major\" style=\"left:0%\"><span class=\"t\">0s</span></span>\n <span class=\"tick minor\" style=\"left:6.67%\"></span>\n <span class=\"tick major\" style=\"left:13.33%\"><span class=\"t\">2s</span></span>\n <span class=\"tick minor\" style=\"left:20%\"></span>\n <span class=\"tick major\" style=\"left:26.67%\"><span class=\"t\">4s</span></span>\n <span class=\"tick minor\" style=\"left:33.33%\"></span>\n <span class=\"tick major\" style=\"left:40%\"><span class=\"t\">6s</span></span>\n <span class=\"tick minor\" style=\"left:46.67%\"></span>\n <span class=\"tick major\" style=\"left:53.33%\"><span class=\"t\">8s</span></span>\n <span class=\"tick minor\" style=\"left:60%\"></span>\n <span class=\"tick major\" style=\"left:66.67%\"><span class=\"t\">10s</span></span>\n <span class=\"tick minor\" style=\"left:73.33%\"></span>\n <span class=\"tick major\" style=\"left:80%\"><span class=\"t\">12s</span></span>\n <span class=\"tick minor\" style=\"left:86.67%\"></span>\n <span class=\"tick major\" style=\"left:93.33%\"><span class=\"t\">14s</span></span>\n <span class=\"tick major\" style=\"left:100%\"><span class=\"t\">15s</span></span>\n </div>\n </div>\n\n <div class=\"tl-track video-track\">\n <div class=\"label video\">\n <span class=\"ico\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2\" y=\"2\" width=\"20\" height=\"20\" rx=\"2.18\"/><path d=\"M7 2v20M17 2v20M2 12h20M2 7h5M2 17h5M17 17h5M17 7h5\"/></svg></span>\n 视频\n </div>\n <div class=\"lane\" id=\"ed-lane-video\" data-track=\"video\">\n <div class=\"clip video\" data-track=\"video\" data-label=\"深夜办公桌\" data-dur=\"2\" data-max=\"2\"><span class=\"frames\"><span class=\"fr\"></span><span class=\"fr\"></span><span class=\"fr\"></span></span><span class=\"num\">1</span><span class=\"lbl\">深夜办公桌</span></div>\n <div class=\"clip video\" data-track=\"video\" data-label=\"面膜包装\" data-dur=\"3\" data-max=\"3\"><span class=\"frames\"><span class=\"fr\"></span><span class=\"fr\"></span><span class=\"fr\"></span><span class=\"fr\"></span></span><span class=\"num\">2</span><span class=\"lbl\">面膜包装</span></div>\n <div class=\"clip video\" data-track=\"video\" data-label=\"精华液微距\" data-dur=\"3\" data-max=\"3\"><span class=\"frames\"><span class=\"fr\"></span><span class=\"fr\"></span><span class=\"fr\"></span><span class=\"fr\"></span></span><span class=\"num\">3</span><span class=\"lbl\">精华液微距</span></div>\n <div class=\"clip video\" data-track=\"video\" data-label=\"敷面膜平躺\" data-dur=\"3\" data-max=\"3\"><span class=\"frames\"><span class=\"fr\"></span><span class=\"fr\"></span><span class=\"fr\"></span><span class=\"fr\"></span></span><span class=\"num\">4</span><span class=\"lbl\">敷面膜平躺</span></div>\n <div class=\"clip video\" data-track=\"video\" data-label=\"化妆台\" data-dur=\"2\" data-max=\"2\"><span class=\"frames\"><span class=\"fr\"></span><span class=\"fr\"></span><span class=\"fr\"></span></span><span class=\"num\">5</span><span class=\"lbl\">化妆台</span></div>\n <div class=\"clip video\" data-track=\"video\" data-label=\"产品定格\" data-dur=\"2\" data-max=\"2\"><span class=\"frames\"><span class=\"fr\"></span><span class=\"fr\"></span><span class=\"fr\"></span></span><span class=\"num\">6</span><span class=\"lbl\">产品定格</span></div>\n </div>\n </div>\n\n <div class=\"tl-track subtitle-track\">\n <div class=\"label subtitle\">\n <span class=\"ico\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 7V4h16v3\"/><path d=\"M9 20h6\"/><path d=\"M12 4v16\"/></svg></span>\n 字幕\n </div>\n <div class=\"lane\" id=\"ed-lane-subtitle\" data-track=\"subtitle\">\n <div class=\"clip subtitle\" data-track=\"subtitle\" data-label=\"加班三天 脸已经不能看了…\" data-dur=\"2\" data-max=\"2\"><span class=\"lbl\">加班三天 脸已经不能看了…</span></div>\n <div class=\"clip subtitle\" data-track=\"subtitle\" data-label=\"还好我有这个 透真玻尿酸面膜\" data-dur=\"3\" data-max=\"3\"><span class=\"lbl\">还好我有这个 透真玻尿酸面膜</span></div>\n <div class=\"clip subtitle\" data-track=\"subtitle\" data-label=\"30g 精华 一片顶三片\" data-dur=\"3\" data-max=\"3\"><span class=\"lbl\">30g 精华 一片顶三片</span></div>\n <div class=\"clip subtitle\" data-track=\"subtitle\" data-label=\"敷完起来脸是软的\" data-dur=\"3\" data-max=\"3\"><span class=\"lbl\">敷完起来脸是软的</span></div>\n <div class=\"clip subtitle\" data-track=\"subtitle\" data-label=\"化妆都能看出来\" data-dur=\"2\" data-max=\"2\"><span class=\"lbl\">化妆都能看出来</span></div>\n <div class=\"clip subtitle\" data-track=\"subtitle\" data-label=\"5 片 ¥39.9 囤起来\" data-dur=\"2\" data-max=\"2\"><span class=\"lbl\">5 片 ¥39.9 囤起来</span></div>\n <div class=\"playhead\" id=\"ed-playhead\" style=\"left:0%;\"><span class=\"ph-grab\"></span></div>\n </div>\n </div>\n\n <div class=\"tl-track bgm-track\">\n <div class=\"label bgm\">\n <span class=\"ico\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 18V5l12-2v13\"/><circle cx=\"6\" cy=\"18\" r=\"3\"/><circle cx=\"18\" cy=\"16\" r=\"3\"/></svg></span>\n BGM\n </div>\n <div class=\"lane\">\n <div class=\"clip bgm\" data-track=\"bgm\" data-label=\"温柔治愈钢琴\" data-dur=\"15\" data-max=\"15\">\n <span class=\"wave\"><svg viewBox=\"0 0 600 20\" preserveAspectRatio=\"none\" fill=\"currentColor\"><rect x=\"0\" y=\"8\" width=\"2\" height=\"4\"/><rect x=\"4\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"8\" y=\"3\" width=\"2\" height=\"14\"/><rect x=\"12\" y=\"7\" width=\"2\" height=\"6\"/><rect x=\"16\" y=\"4\" width=\"2\" height=\"12\"/><rect x=\"20\" y=\"2\" width=\"2\" height=\"16\"/><rect x=\"24\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"28\" y=\"8\" width=\"2\" height=\"4\"/><rect x=\"32\" y=\"5\" width=\"2\" height=\"10\"/><rect x=\"36\" y=\"3\" width=\"2\" height=\"14\"/><rect x=\"40\" y=\"7\" width=\"2\" height=\"6\"/><rect x=\"44\" y=\"4\" width=\"2\" height=\"12\"/><rect x=\"48\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"52\" y=\"2\" width=\"2\" height=\"16\"/><rect x=\"56\" y=\"5\" width=\"2\" height=\"10\"/><rect x=\"60\" y=\"7\" width=\"2\" height=\"6\"/><rect x=\"64\" y=\"3\" width=\"2\" height=\"14\"/><rect x=\"68\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"72\" y=\"8\" width=\"2\" height=\"4\"/><rect x=\"76\" y=\"4\" width=\"2\" height=\"12\"/><rect x=\"80\" y=\"2\" width=\"2\" height=\"16\"/><rect x=\"84\" y=\"5\" width=\"2\" height=\"10\"/><rect x=\"88\" y=\"7\" width=\"2\" height=\"6\"/><rect x=\"92\" y=\"3\" width=\"2\" height=\"14\"/><rect x=\"96\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"100\" y=\"4\" width=\"2\" height=\"12\"/><rect x=\"104\" y=\"8\" width=\"2\" height=\"4\"/><rect x=\"108\" y=\"5\" width=\"2\" height=\"10\"/><rect x=\"112\" y=\"2\" width=\"2\" height=\"16\"/><rect x=\"116\" y=\"7\" width=\"2\" height=\"6\"/><rect x=\"120\" y=\"3\" width=\"2\" height=\"14\"/><rect x=\"124\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"128\" y=\"4\" width=\"2\" height=\"12\"/><rect x=\"132\" y=\"8\" width=\"2\" height=\"4\"/><rect x=\"136\" y=\"5\" width=\"2\" height=\"10\"/><rect x=\"140\" y=\"3\" width=\"2\" height=\"14\"/><rect x=\"144\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"148\" y=\"2\" width=\"2\" height=\"16\"/><rect x=\"152\" y=\"7\" width=\"2\" height=\"6\"/><rect x=\"156\" y=\"4\" width=\"2\" height=\"12\"/><rect x=\"160\" y=\"5\" width=\"2\" height=\"10\"/><rect x=\"164\" y=\"3\" width=\"2\" height=\"14\"/><rect x=\"168\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"172\" y=\"8\" width=\"2\" height=\"4\"/><rect x=\"176\" y=\"4\" width=\"2\" height=\"12\"/><rect x=\"180\" y=\"2\" width=\"2\" height=\"16\"/><rect x=\"184\" y=\"5\" width=\"2\" height=\"10\"/><rect x=\"188\" y=\"7\" width=\"2\" height=\"6\"/><rect x=\"192\" y=\"3\" width=\"2\" height=\"14\"/><rect x=\"196\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"200\" y=\"8\" width=\"2\" height=\"4\"/><rect x=\"204\" y=\"4\" width=\"2\" height=\"12\"/><rect x=\"208\" y=\"2\" width=\"2\" height=\"16\"/><rect x=\"212\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"216\" y=\"5\" width=\"2\" height=\"10\"/><rect x=\"220\" y=\"3\" width=\"2\" height=\"14\"/><rect x=\"224\" y=\"7\" width=\"2\" height=\"6\"/><rect x=\"228\" y=\"4\" width=\"2\" height=\"12\"/><rect x=\"232\" y=\"8\" width=\"2\" height=\"4\"/><rect x=\"236\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"240\" y=\"2\" width=\"2\" height=\"16\"/><rect x=\"244\" y=\"5\" width=\"2\" height=\"10\"/><rect x=\"248\" y=\"3\" width=\"2\" height=\"14\"/><rect x=\"252\" y=\"7\" width=\"2\" height=\"6\"/><rect x=\"256\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"260\" y=\"4\" width=\"2\" height=\"12\"/><rect x=\"264\" y=\"8\" width=\"2\" height=\"4\"/><rect x=\"268\" y=\"5\" width=\"2\" height=\"10\"/><rect x=\"272\" y=\"2\" width=\"2\" height=\"16\"/><rect x=\"276\" y=\"7\" width=\"2\" height=\"6\"/><rect x=\"280\" y=\"3\" width=\"2\" height=\"14\"/><rect x=\"284\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"288\" y=\"4\" width=\"2\" height=\"12\"/><rect x=\"292\" y=\"8\" width=\"2\" height=\"4\"/><rect x=\"296\" y=\"5\" width=\"2\" height=\"10\"/><rect x=\"300\" y=\"3\" width=\"2\" height=\"14\"/><rect x=\"304\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"308\" y=\"2\" width=\"2\" height=\"16\"/><rect x=\"312\" y=\"7\" width=\"2\" height=\"6\"/><rect x=\"316\" y=\"4\" width=\"2\" height=\"12\"/><rect x=\"320\" y=\"5\" width=\"2\" height=\"10\"/><rect x=\"324\" y=\"3\" width=\"2\" height=\"14\"/><rect x=\"328\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"332\" y=\"8\" width=\"2\" height=\"4\"/><rect x=\"336\" y=\"4\" width=\"2\" height=\"12\"/><rect x=\"340\" y=\"2\" width=\"2\" height=\"16\"/><rect x=\"344\" y=\"5\" width=\"2\" height=\"10\"/><rect x=\"348\" y=\"7\" width=\"2\" height=\"6\"/><rect x=\"352\" y=\"3\" width=\"2\" height=\"14\"/><rect x=\"356\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"360\" y=\"8\" width=\"2\" height=\"4\"/><rect x=\"364\" y=\"4\" width=\"2\" height=\"12\"/><rect x=\"368\" y=\"2\" width=\"2\" height=\"16\"/><rect x=\"372\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"376\" y=\"5\" width=\"2\" height=\"10\"/><rect x=\"380\" y=\"3\" width=\"2\" height=\"14\"/><rect x=\"384\" y=\"7\" width=\"2\" height=\"6\"/><rect x=\"388\" y=\"4\" width=\"2\" height=\"12\"/><rect x=\"392\" y=\"8\" width=\"2\" height=\"4\"/><rect x=\"396\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"400\" y=\"2\" width=\"2\" height=\"16\"/><rect x=\"404\" y=\"5\" width=\"2\" height=\"10\"/><rect x=\"408\" y=\"3\" width=\"2\" height=\"14\"/><rect x=\"412\" y=\"7\" width=\"2\" height=\"6\"/><rect x=\"416\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"420\" y=\"4\" width=\"2\" height=\"12\"/><rect x=\"424\" y=\"8\" width=\"2\" height=\"4\"/><rect x=\"428\" y=\"5\" width=\"2\" height=\"10\"/><rect x=\"432\" y=\"2\" width=\"2\" height=\"16\"/><rect x=\"436\" y=\"7\" width=\"2\" height=\"6\"/><rect x=\"440\" y=\"3\" width=\"2\" height=\"14\"/><rect x=\"444\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"448\" y=\"4\" width=\"2\" height=\"12\"/><rect x=\"452\" y=\"8\" width=\"2\" height=\"4\"/><rect x=\"456\" y=\"5\" width=\"2\" height=\"10\"/><rect x=\"460\" y=\"3\" width=\"2\" height=\"14\"/><rect x=\"464\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"468\" y=\"2\" width=\"2\" height=\"16\"/><rect x=\"472\" y=\"7\" width=\"2\" height=\"6\"/><rect x=\"476\" y=\"4\" width=\"2\" height=\"12\"/><rect x=\"480\" y=\"5\" width=\"2\" height=\"10\"/><rect x=\"484\" y=\"3\" width=\"2\" height=\"14\"/><rect x=\"488\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"492\" y=\"8\" width=\"2\" height=\"4\"/><rect x=\"496\" y=\"4\" width=\"2\" height=\"12\"/><rect x=\"500\" y=\"2\" width=\"2\" height=\"16\"/><rect x=\"504\" y=\"5\" width=\"2\" height=\"10\"/><rect x=\"508\" y=\"7\" width=\"2\" height=\"6\"/><rect x=\"512\" y=\"3\" width=\"2\" height=\"14\"/><rect x=\"516\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"520\" y=\"8\" width=\"2\" height=\"4\"/><rect x=\"524\" y=\"4\" width=\"2\" height=\"12\"/><rect x=\"528\" y=\"2\" width=\"2\" height=\"16\"/><rect x=\"532\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"536\" y=\"5\" width=\"2\" height=\"10\"/><rect x=\"540\" y=\"3\" width=\"2\" height=\"14\"/><rect x=\"544\" y=\"7\" width=\"2\" height=\"6\"/><rect x=\"548\" y=\"4\" width=\"2\" height=\"12\"/><rect x=\"552\" y=\"8\" width=\"2\" height=\"4\"/><rect x=\"556\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"560\" y=\"2\" width=\"2\" height=\"16\"/><rect x=\"564\" y=\"5\" width=\"2\" height=\"10\"/><rect x=\"568\" y=\"3\" width=\"2\" height=\"14\"/><rect x=\"572\" y=\"7\" width=\"2\" height=\"6\"/><rect x=\"576\" y=\"6\" width=\"2\" height=\"8\"/><rect x=\"580\" y=\"4\" width=\"2\" height=\"12\"/><rect x=\"584\" y=\"8\" width=\"2\" height=\"4\"/><rect x=\"588\" y=\"5\" width=\"2\" height=\"10\"/><rect x=\"592\" y=\"3\" width=\"2\" height=\"14\"/><rect x=\"596\" y=\"6\" width=\"2\" height=\"8\"/></svg></span>\n <span class=\"lbl\">温柔治愈钢琴 · 0:42(循环 1 次,淡入淡出)</span>\n </div>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"stage-foot\">\n <div class=\"info\"><span class=\"mono\">[ 合成预估 ~30s · 拼接 / 导出全程 0 token · 已结算 ¥1.39 ]</span></div>\n <div class=\"hstack\">\n <button class=\"btn\" onclick=\"location.hash='#stage-4'\"><svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19 12H5M12 19l-7-7 7-7\"/></svg> 返回片段</button>\n <button class=\"btn\" onclick=\"Shell.toast('已保存草稿', '/projects/p3/draft')\">保存草稿</button>\n <button class=\"btn btn-primary btn-lg\" onclick=\"Shell.toast('开始导出', 'POST /export · 1080P 9:16')\">导出 MP4 · 1080P 9:16 <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 4v12m0 0l-5-5m5 5l5-5M4 20h16\"/></svg></button>\n </div>\n </div>\n</section>\n\n<!-- ===== Stage 2 通用 · 资产详情 modal (参考布局 v2) ===== -->\n<div class=\"asset-modal-bg\" id=\"asset-detail-modal\">\n <div class=\"asset-modal\">\n <div class=\"asset-modal-h\">\n <h2 id=\"asset-detail-title\">资产详情</h2>\n <span class=\"ad-tag\" id=\"asset-detail-kind\">/ kind</span>\n <button class=\"x\" type=\"button\" aria-label=\"关闭\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6 6l12 12M6 18L18 6\"/></svg></button>\n </div>\n <div class=\"asset-modal-body\">\n <div class=\"asset-detail-grid\">\n <!-- 左栏 · 大立绘 + 缩略图 -->\n <div class=\"asset-detail-lead\">\n <div class=\"ad-lead-wrap\">\n <div class=\"placeholder ad-lead-img\" id=\"asset-detail-lead-img\"><span class=\"ph-frame\">立绘</span></div>\n <button class=\"ad-zoom-btn\" type=\"button\" id=\"asset-detail-zoom-btn\" aria-label=\"查看大图\" title=\"查看大图\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5\"/></svg>\n </button>\n </div>\n <div class=\"ad-thumbs\" id=\"asset-detail-thumbs\"></div>\n </div>\n <!-- 右栏 · 三视图 + 简介 + 属性 -->\n <div class=\"asset-detail-right\">\n <!-- 三视图 -->\n <div class=\"ad-section\" id=\"asset-detail-tri-section\">\n <div class=\"asset-detail-section-h\">\n <span class=\"ic\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"7\" height=\"7\"/><rect x=\"14\" y=\"3\" width=\"7\" height=\"7\"/><rect x=\"3\" y=\"14\" width=\"7\" height=\"7\"/></svg></span>\n <span class=\"t\">三视图</span>\n <span class=\"ad-ratio-chip\" id=\"asset-detail-ratio\">16:9</span>\n <button class=\"ad-icon-btn\" type=\"button\" title=\"下载\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3\"/></svg>\n </button>\n </div>\n <div class=\"asset-detail-tri-row\" id=\"asset-detail-tri\">\n <div class=\"placeholder\"><span class=\"ph-frame\">正 / 侧 / 背 · 三视图</span></div>\n </div>\n <div class=\"asset-detail-tip\" id=\"asset-detail-tip\" style=\"display:none;\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 8v4M12 16h.01\"/></svg>\n <span>暂无三视图,建议用 AI 生成以保证多角度一致性</span>\n <button class=\"ai-gen-btn\" type=\"button\">AI 生成三视图</button>\n </div>\n </div>\n <!-- 简介 -->\n <div class=\"ad-section\">\n <div class=\"asset-detail-section-h\">\n <span class=\"ic\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 6h16M4 12h16M4 18h10\"/></svg></span>\n <span class=\"t\">简介</span>\n </div>\n <p class=\"ad-intro\" id=\"asset-detail-intro\"></p>\n <div class=\"ad-tags\" id=\"asset-detail-tags\"></div>\n </div>\n <!-- 属性表 -->\n <div class=\"ad-props\" id=\"asset-detail-props\"></div>\n </div>\n </div>\n </div>\n <div class=\"asset-modal-f\">\n <div class=\"ad-foot-stats\">\n <button class=\"ad-stat-btn\" type=\"button\" id=\"asset-detail-download\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3\"/></svg>\n 下载\n </button>\n </div>\n <button class=\"btn btn-primary\" type=\"button\" id=\"asset-detail-apply-btn\">使用该资产</button>\n </div>\n </div>\n</div>\n\n<!-- ===== Stage 2 · 新增人物 modal ===== -->\n<div class=\"asset-modal-bg\" id=\"new-character-modal\">\n <div class=\"asset-modal\" style=\"width: min(680px, 100%);\">\n <div class=\"asset-modal-h\">\n <h2>新增人物</h2>\n <span style=\"font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48);\">// 立绘必填 + 三视图(可 AI 生成)</span>\n <button class=\"x\" type=\"button\" aria-label=\"关闭\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6 6l12 12M6 18L18 6\"/></svg></button>\n </div>\n <div class=\"asset-modal-body\">\n <div class=\"field\" style=\"margin-bottom: 14px;\">\n <label class=\"field-label\" style=\"display:block; font-size: 12.5px; color: var(--black-alpha-56); margin-bottom: 6px;\">人物名称</label>\n <input class=\"input\" type=\"text\" placeholder=\"例:林夕 · 都市白领\" style=\"width:100%; height:36px; padding:0 12px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); font-size:13px; font-family: inherit;\">\n </div>\n <div style=\"display:grid; grid-template-columns: 1fr 1fr; gap: 14px;\">\n <div>\n <div class=\"asset-detail-section-h\">// 立绘<span style=\"color: var(--heat); margin-left:2px;\">*</span></div>\n <div class=\"upload-zone lead\" id=\"nc-upload-lead\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12\"/></svg>\n <span>点击上传立绘</span>\n <span style=\"font-family: var(--font-mono); font-size: 10.5px; opacity: .7;\">PNG / JPG · ≤10MB</span>\n </div>\n </div>\n <div>\n <div class=\"asset-detail-section-h\">// 三视图<span style=\"color: var(--black-alpha-48); font-weight:400; margin-left: 4px; text-transform: none; letter-spacing: 0;\">(可选 · 16:9 单图)</span></div>\n <div>\n <div class=\"upload-zone upload-zone-tri\" style=\"aspect-ratio: 16/9;\"><span>正 / 侧 / 背 · 三视图</span></div>\n </div>\n <div class=\"asset-detail-tip\" id=\"nc-tri-tip\" style=\"margin-top: 10px;\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 8v4M12 16h.01\"/></svg>\n <span>没有三视图?上传立绘后用 AI 自动生成</span>\n <button class=\"ai-gen-btn\" type=\"button\">AI 生成</button>\n </div>\n </div>\n </div>\n </div>\n <div class=\"asset-modal-f\">\n <button class=\"btn btn-ghost\" type=\"button\" data-modal-close>取消</button>\n <button class=\"btn btn-primary\" type=\"button\" id=\"nc-save-btn\">保存人物</button>\n </div>\n </div>\n</div>\n\n<!-- ===== Stage 2 · 演员库 / 场景库 全屏弹窗(共享 · kind 切换内容)===== -->\n<div class=\"ml-modal-bg\" id=\"ml-modal-bg\">\n <div class=\"ml-modal\">\n <div class=\"ml-modal-h\">\n <h2 id=\"ml-modal-title\">演员库</h2>\n <span class=\"ct\" id=\"ml-modal-ct\">// 共 0 个</span>\n <button class=\"x\" type=\"button\" id=\"ml-close-btn\" aria-label=\"关闭\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6 6l12 12M6 18L18 6\"/></svg>\n </button>\n </div>\n <div class=\"ml-modal-body\">\n <aside class=\"ml-side\" id=\"ml-side\">\n <!-- JS 注入 来源 -->\n </aside>\n <div class=\"ml-main\">\n <div class=\"ml-toolbar\" id=\"ml-toolbar\">\n <!-- JS 注入 chips + 上传按钮 -->\n </div>\n <div class=\"ml-scroll\">\n <div class=\"ml-grid\" id=\"ml-grid\">\n <!-- JS 注入 卡片 -->\n </div>\n </div>\n\n <!-- 添加演员 / 场景 · 工作台画布 -->\n <div class=\"ml-canvas\" id=\"ml-canvas\" aria-hidden=\"true\">\n <div class=\"ml-canvas-h\">\n <button class=\"back-btn\" type=\"button\" id=\"ml-canvas-back\" aria-label=\"返回\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19 12H5M12 19l-7-7 7-7\"/></svg>\n 返回\n </button>\n <h3 id=\"ml-canvas-title\">添加演员</h3>\n <span class=\"mono\" id=\"ml-canvas-mono\">// 添加演员 · 工作台</span>\n <span style=\"flex:1;\"></span>\n </div>\n <div class=\"ml-canvas-body\">\n <section class=\"mc-ai\">\n <div class=\"mc-stream\" id=\"mc-stream\">\n <div class=\"mc-stream-inner\" id=\"mc-stream-inner\">\n <div class=\"mc-empty\">\n <div class=\"ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 2l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 2z\"/><path d=\"M19 16l.85 2.3L22 19l-2.15.7L19 22l-.85-2.3L16 19l2.15-.7L19 16z\"/></svg>\n </div>\n <span class=\"badge\">// AI · STUDIO</span>\n <h2 id=\"mc-empty-title\">用 AI 生成一位新演员</h2>\n <p id=\"mc-empty-desc\">描述外形 + 风格 + 服装,AI 会同时生成立绘 + 正/侧/背三视图,加入演员库。</p>\n <div class=\"examples\" id=\"mc-empty-examples\"></div>\n </div>\n </div>\n </div>\n <div class=\"mc-input-wrap\">\n <div class=\"mc-input\">\n <div class=\"mc-input-top\">\n <div class=\"mc-input-refs\" id=\"mc-input-refs\"></div>\n <button class=\"add-btn\" type=\"button\" id=\"mc-add-btn\" title=\"上传参考图\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 5v14M5 12h14\"/></svg>\n </button>\n <input type=\"file\" id=\"mc-ai-ref-input\" accept=\"image/*\" multiple hidden>\n </div>\n <textarea id=\"mc-input-text\" rows=\"1\" placeholder=\"描述外形、风格、服饰…\"></textarea>\n <div class=\"mc-input-bottom\">\n <div class=\"param\"><span class=\"lbl-mono\">比例</span><span>3:4</span><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></div>\n <div class=\"param\"><span class=\"lbl-mono\">风格</span><span>默认</span><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></div>\n <div class=\"param\"><span class=\"lbl-mono\">张数</span><span>4</span><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></div>\n <span class=\"right-meta\">预估 <span class=\"val\">¥0.80</span> · 余额 <span class=\"val\">¥327.40</span></span>\n <button class=\"send-btn\" type=\"button\" id=\"mc-send-btn\" disabled title=\"生成\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12h14M12 5l7 7-7 7\"/></svg>\n </button>\n </div>\n </div>\n </div>\n </section>\n\n <aside class=\"mc-up\" id=\"mc-up\" data-kind=\"actor\">\n <div class=\"mc-up-tabs\">\n <button class=\"mc-up-tab active\" type=\"button\" data-tab=\"ai\">AI 生成</button>\n <button class=\"mc-up-tab\" type=\"button\" data-tab=\"local\">本地上传</button>\n </div>\n <div class=\"mc-up-body\">\n <div class=\"mc-up-section\">\n <div class=\"mc-up-sec-h\" id=\"mc-up-name-label\">// 演员姓名</div>\n <input class=\"mc-up-name\" type=\"text\" id=\"mc-up-name\" placeholder=\"给演员起个名字…\" maxlength=\"20\">\n </div>\n <div class=\"mc-up-section\">\n <div class=\"mc-up-sec-h\" id=\"mc-up-portrait-label\">// 演员立绘</div>\n <div class=\"mc-portrait-ai\" data-show=\"ai\">\n <div class=\"empty\" id=\"mc-portrait-ai-empty\">\n <div class=\"ic\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"9\" cy=\"8\" r=\"4\"/><path d=\"M3 21c0-3.5 3-6 6-6s6 2.5 6 6\"/></svg></div>\n <div class=\"desc\" id=\"mc-portrait-ai-empty-desc\">在左侧 AI 生成后<br>点击想要的立绘添加到这里</div>\n <div class=\"mono\">// 待选中</div>\n </div>\n <div class=\"picked\" id=\"mc-portrait-ai-picked\" hidden>\n <span class=\"badge\">已选用</span>\n <div class=\"ph-frame\" id=\"mc-portrait-ai-label\">立绘</div>\n <div class=\"ops\">\n <button type=\"button\" id=\"mc-portrait-ai-clear\" title=\"移除\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M18 6L6 18M6 6l12 12\"/></svg>\n </button>\n </div>\n </div>\n </div>\n <div class=\"mc-portrait-local\" data-show=\"local\" hidden>\n <div class=\"drop\" id=\"mc-portrait-local-drop\" tabindex=\"0\" role=\"button\" aria-label=\"点击或拖入立绘\">\n <div class=\"ic\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4\"/><polyline points=\"17 8 12 3 7 8\"/><line x1=\"12\" y1=\"3\" x2=\"12\" y2=\"15\"/></svg></div>\n <div class=\"t\" id=\"mc-portrait-local-t\">点击或拖入立绘</div>\n <div class=\"d\">支持多张 JPG / PNG / WEBP · ≤ 10MB / 张</div>\n </div>\n <div class=\"list-h\">\n <span>// 已上传</span>\n <span class=\"ct\" id=\"mc-portrait-local-count\">0</span>\n <span>张</span>\n </div>\n <div class=\"list\" id=\"mc-portrait-local-list\"></div>\n <input type=\"file\" id=\"mc-portrait-local-input\" accept=\"image/*\" multiple hidden>\n </div>\n </div>\n <div class=\"mc-up-section mc-triview\" id=\"mc-triview-sec\">\n <div class=\"mc-up-sec-h\">// 三视图</div>\n <div class=\"result-wrap\">\n <div class=\"result\" id=\"mc-triview-result\">\n <div class=\"ph-frame\" id=\"mc-triview-frame\">三视图(正/侧/背)</div>\n <button class=\"overlay-gen-btn\" type=\"button\" id=\"mc-triview-gen-btn\" disabled>\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8L12 3z\"/></svg>\n 生成三视图\n </button>\n <div class=\"overlay-hint\" id=\"mc-triview-hint\">// 先选中左侧 AI 立绘</div>\n </div>\n <div class=\"result-ops\" id=\"mc-triview-ops\" hidden>\n <button type=\"button\" id=\"mc-triview-rerun\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 12a9 9 0 1 0 3-6.7\"/><path d=\"M3 4v5h5\"/></svg> 重跑</button>\n <span class=\"cost\">~¥0.30 / 次</span>\n </div>\n <div class=\"history\" id=\"mc-triview-history\" hidden>\n <div class=\"h-lbl\">// 历史版本 · <span class=\"ct\" id=\"mc-triview-history-count\">0</span> 版</div>\n <div class=\"h-row\" id=\"mc-triview-history-row\"></div>\n </div>\n </div>\n </div>\n </div>\n <div class=\"mc-up-foot\">\n <span class=\"stat\" id=\"mc-up-stat\">// 待完成</span>\n <button type=\"button\" class=\"commit-btn\" id=\"mc-up-commit\" disabled>\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 12l5 5L20 6\"/></svg>\n <span id=\"mc-up-commit-label\">加入演员库</span>\n </button>\n </div>\n </aside>\n </div>\n </div>\n </div>\n </div>\n </div>\n</div>\n\n<!-- ===== 离开工作台 · 二次确认弹窗 ===== -->\n<div class=\"mc-leave-bg\" id=\"mc-leave-bg\" aria-hidden=\"true\">\n <div class=\"mc-leave\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"mc-leave-title\">\n <div class=\"lv-h\">\n <span class=\"ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 9v4M12 17h.01M10.3 3.86l-8.18 14.18A2 2 0 0 0 3.84 21h16.32a2 2 0 0 0 1.72-2.96L13.7 3.86a2 2 0 0 0-3.4 0z\"/></svg>\n </span>\n <h3 id=\"mc-leave-title\">退出工作台?</h3>\n <span class=\"mono\">// UNSAVED</span>\n </div>\n <div class=\"lv-b\" id=\"mc-leave-body\">\n 工作台已有内容,退出后<b>不会保存</b>。可继续编辑并点「加入」来保留进度。\n </div>\n <div class=\"lv-f\">\n <span class=\"spacer\"></span>\n <button class=\"btn\" type=\"button\" id=\"mc-leave-cancel\">继续编辑</button>\n <button class=\"btn btn-danger\" type=\"button\" id=\"mc-leave-confirm\">不保存,退出</button>\n </div>\n </div>\n</div>\n\n<!-- ===== 添加演员 / 场景 · 选择来源 modal ===== -->\n<div class=\"ml-up-choice-bg\" id=\"ml-up-choice-bg\">\n <div class=\"ml-up-choice\" role=\"dialog\" aria-label=\"添加来源\">\n <div class=\"uc-h\">\n <div class=\"ic-m\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"9\" cy=\"8\" r=\"4\"/><path d=\"M3 21c0-3.5 3-6 6-6s6 2.5 6 6\"/><path d=\"M19 8v6M22 11h-6\"/></svg>\n </div>\n <div class=\"ti\">\n <strong id=\"ml-up-title\">添加</strong>\n <span class=\"mono\">// 选择来源 · AI 生成或本地上传</span>\n </div>\n <button class=\"uc-x\" type=\"button\" id=\"ml-up-x\" aria-label=\"关闭\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\"><path d=\"M18 6L6 18M6 6l12 12\"/></svg>\n </button>\n </div>\n <div class=\"uc-body\">\n <button type=\"button\" class=\"uc-option\" id=\"ml-up-ai\">\n <span class=\"opt-ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 2l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 2z\"/><path d=\"M19 16l.85 2.3L22 19l-2.15.7L19 22l-.85-2.3L16 19l2.15-.7L19 16z\"/></svg>\n </span>\n <div class=\"opt-t\">AI 生成</div>\n <div class=\"opt-d\" id=\"ml-up-ai-desc\">描述外形 + 风格,AI 自动生成新形象与三视图</div>\n <span class=\"opt-tag\">[ AI · STUDIO ]</span>\n </button>\n <button type=\"button\" class=\"uc-option\" id=\"ml-up-local\">\n <span class=\"opt-ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4\"/><polyline points=\"17 8 12 3 7 8\"/><line x1=\"12\" y1=\"3\" x2=\"12\" y2=\"15\"/></svg>\n </span>\n <div class=\"opt-t\">本地上传</div>\n <div class=\"opt-d\" id=\"ml-up-local-desc\">上传真人 / 既有素材,后续可生成三视图统一镜头</div>\n <span class=\"opt-tag\">[ UPLOAD ]</span>\n </button>\n </div>\n </div>\n</div>\n<input type=\"file\" id=\"ml-up-file\" accept=\"image/*\" multiple hidden>\n\n<!-- ===== 额度预检 modal · PRD §10.3 四层预检 ===== -->\n<div class=\"modal-bg\" id=\"quota-bg\" onclick=\"if(event.target===this)Shell.closeModal('quota-bg')\">\n <div class=\"modal\" id=\"quota-modal\" style=\"width: min(440px, 92vw);\">\n <span class=\"corner-tr\" aria-hidden></span><span class=\"corner-bl\" aria-hidden></span>\n <div class=\"modal-h\">\n <div class=\"ic-m\" id=\"quota-ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M20 6L9 17l-5-5\"/></svg>\n </div>\n <div class=\"ti\" id=\"quota-title\">额度预检通过<span id=\"quota-sub\">// 4 层检查 · 全部通过</span></div>\n </div>\n <div class=\"modal-b\">\n <div id=\"quota-stage-row\" style=\"font-size:13px; color:var(--black-alpha-72); margin-bottom: 14px;\"></div>\n <div style=\"display:flex; flex-direction:column; gap:8px;\" id=\"quota-checks\">\n <!-- JS 注入 4 行检查 -->\n </div>\n <div id=\"quota-block-tip\" style=\"display:none; margin-top:14px; padding:10px 12px; background: rgba(235,52,36,.08); border: 1px solid rgba(235,52,36,.24); border-radius: var(--r-sm); font-size:12.5px; color: var(--accent-crimson); line-height:1.5;\">\n <strong>任务已拦截</strong> · 余额或额度不足以覆盖本次预估。请联系超管充值,或将团队月限额调高。\n </div>\n </div>\n <div class=\"modal-f\">\n <button class=\"btn\" type=\"button\" onclick=\"Shell.closeModal('quota-bg')\" id=\"quota-cancel\">关闭</button>\n <button class=\"btn btn-primary\" type=\"button\" id=\"quota-confirm\" style=\"display:none;\">确认扣费 · 开始任务</button>\n <a class=\"btn btn-primary\" id=\"quota-topup\" href=\"account.html\" style=\"display:none;\">前往充值</a>\n </div>\n </div>\n</div>\n\n</div>\n<script src=\"/exact/assets/icons.js?v=2026052608\"></script>\n<script src=\"/exact/assets/shell.js?v=2026060101\"></script>\n<script>\n/* ─── 商品名贯穿全流程(从 ?product= 读取,无参数时回退到 mock 默认值)─── */\nconst URL_PRODUCT_NAME = (function () {\n try { return decodeURIComponent(new URLSearchParams(location.search).get('product') || ''); }\n catch (e) { return ''; }\n})();\nconst URL_PROJECT_VER = (function () {\n try { return new URLSearchParams(location.search).get('v') || 'v3'; }\n catch (e) { return 'v3'; }\n})();\nconst CURRENT_PRODUCT_NAME = URL_PRODUCT_NAME || '透真补水面膜';\n// 项目名 = 「{product 简称} · 痛点种草 · v3」 — 简称取末 4 字符作为视觉收敛\nfunction shortProductName(name) {\n if (name.length <= 5) return name;\n // 尝试匹配常见品类后缀,否则取末 4 字\n const suffixes = ['面膜', '防晒', '口红', '耳机', '速食面', '咖啡', '瑜伽裤', '保温杯'];\n for (const s of suffixes) { if (name.endsWith(s)) return name.slice(-Math.max(s.length + 2, 4)); }\n return name.slice(-4);\n}\nconst PROJECT_TITLE = shortProductName(CURRENT_PRODUCT_NAME) + ' · 痛点种草 · ' + URL_PROJECT_VER;\n\nShell.render({\n active: 'projects',\n crumbs: []\n});\n\n/* 渲染贯穿商品名 / 项目名 */\ndocument.getElementById('page-title').textContent = PROJECT_TITLE + ' · 流水线 · Airshelf';\n\n(function _injectPipelineTopbarLeft() {\n const topbar = document.querySelector('.topbar');\n const right = topbar?.querySelector('.right');\n if (!topbar || !right) return;\n const esc = s => String(s).replace(/[&<>\"']/g, c => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[c]));\n const title = esc(PROJECT_TITLE);\n const left = document.createElement('div');\n left.className = 'pipeline-topbar-left';\n left.innerHTML = `\n <a class=\"btn btn-ghost pipeline-back\" href=\"projects.html\" aria-label=\"返回视频项目\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"/><path d=\"M12 19l-7-7 7-7\"/></svg>\n 返回视频项目\n </a>\n <div class=\"pipeline-topbar-title\" title=\"${title}\">\n ${title}<span class=\"mono\">// PIPELINE</span>\n </div>\n `;\n topbar.insertBefore(left, right);\n})();\n\nconst ProjectStore = (function () {\n const safeId = (PROJECT_TITLE + '|' + CURRENT_PRODUCT_NAME).replace(/[^\\w\\u4e00-\\u9fa5-]+/g, '_');\n const key = 'airshelf:pipeline:' + safeId;\n const defaults = {\n product: CURRENT_PRODUCT_NAME,\n title: PROJECT_TITLE,\n currentStage: 1,\n completedStage: 0,\n fields: {},\n actions: [],\n jobs: {},\n stage1: null,\n stage2: {},\n stage3: null,\n stage4: null,\n updatedAt: Date.now(),\n };\n let data;\n try {\n data = { ...defaults, ...(JSON.parse(localStorage.getItem(key) || '{}') || {}) };\n } catch (e) {\n data = { ...defaults };\n }\n\n function save() {\n data.updatedAt = Date.now();\n localStorage.setItem(key, JSON.stringify(data));\n try {\n const indexKey = 'airshelf:pipeline-index';\n const list = JSON.parse(localStorage.getItem(indexKey) || '[]');\n const compact = {\n key,\n title: data.title,\n product: data.product,\n currentStage: data.currentStage,\n completedStage: data.completedStage,\n updatedAt: data.updatedAt,\n runningJobs: Object.values(data.jobs || {})\n .filter(j => j.status === 'running')\n .map(j => ({ stage: j.stage, label: j.label, finishAt: j.finishAt })),\n };\n const next = [compact, ...list.filter(item => item.key !== key)].slice(0, 30);\n localStorage.setItem(indexKey, JSON.stringify(next));\n } catch (e) {}\n }\n\n function record(type, detail = {}) {\n data.actions = data.actions || [];\n data.actions.unshift({ type, detail, at: Date.now() });\n data.actions = data.actions.slice(0, 80);\n save();\n }\n\n function setStage(n) {\n data.currentStage = Number(n) || 1;\n data.completedStage = Math.max(Number(data.completedStage) || 0, Math.max(0, data.currentStage - 1));\n save();\n }\n\n function saveFieldsFrom(root = document) {\n root.querySelectorAll('[id][contenteditable=\"true\"], input[id], textarea[id], select[id]').forEach(el => {\n if (el.type === 'file') return;\n data.fields[el.id] = {\n kind: el.matches('[contenteditable=\"true\"]') ? 'text' : 'value',\n value: el.matches('[contenteditable=\"true\"]') ? el.textContent : el.value,\n };\n });\n save();\n }\n\n function restoreFields(root = document) {\n Object.entries(data.fields || {}).forEach(([id, item]) => {\n const el = root.getElementById ? root.getElementById(id) : document.getElementById(id);\n if (!el || item.value == null) return;\n if (item.kind === 'text' && el.matches('[contenteditable=\"true\"]')) el.textContent = item.value;\n else if ('value' in el && el.type !== 'file') el.value = item.value;\n });\n }\n\n function startJob(id, payload) {\n data.jobs[id] = { ...payload, status: 'running', startedAt: Date.now(), updatedAt: Date.now() };\n save();\n }\n\n function finishJob(id, patch = {}) {\n if (!data.jobs[id]) return;\n data.jobs[id] = { ...data.jobs[id], ...patch, status: 'done', finishedAt: Date.now(), updatedAt: Date.now() };\n save();\n }\n\n function getJob(id) { return data.jobs?.[id] || null; }\n function clearJob(id) { if (data.jobs?.[id]) { delete data.jobs[id]; save(); } }\n\n function saveStage(name, value) {\n data[name] = value;\n save();\n }\n\n window.addEventListener('beforeunload', () => saveFieldsFrom());\n document.addEventListener('input', (e) => {\n if (e.target.closest('[contenteditable=\"true\"], input[id], textarea[id], select[id]')) {\n saveFieldsFrom();\n }\n });\n\n return { key, data, save, record, setStage, saveFieldsFrom, restoreFields, startJob, finishJob, getJob, clearJob, saveStage };\n})();\n\n/* ─── 把 stage-pill anchor 注入 .topbar 中部(圆点全状态都靠 .sp-dot 实时同步)─── */\n(function _injectStagePill() {\n const anchor = document.getElementById('stage-pill-anchor');\n const topbar = document.querySelector('.topbar');\n if (!anchor || !topbar) return;\n anchor.classList.add('stage-pill');\n anchor.classList.remove('stage-pill-anchor');\n anchor.removeAttribute('hidden');\n anchor.id = 'stage-pill';\n topbar.appendChild(anchor); // position: absolute · 自动锚定 topbar 中部\n})();\n\n/* ─── Stage 1 拖拽分隔条 · 控制脚本助手宽度 · clamp 在 [380, 680] ─── */\n(function _setupStageScriptGutter() {\n const gutter = document.getElementById('stage-script-gutter');\n const grid = document.querySelector('.stage-script');\n if (!gutter || !grid) return;\n const MIN = 380, MAX = 680;\n let dragging = false;\n gutter.addEventListener('mousedown', (e) => {\n dragging = true;\n gutter.classList.add('dragging');\n document.body.style.cursor = 'col-resize';\n document.body.style.userSelect = 'none';\n e.preventDefault();\n });\n document.addEventListener('mousemove', (e) => {\n if (!dragging) return;\n const rect = grid.getBoundingClientRect();\n const newRight = rect.right - e.clientX;\n const clamped = Math.max(MIN, Math.min(MAX, newRight));\n grid.style.setProperty('--chat-w', clamped + 'px');\n });\n document.addEventListener('mouseup', () => {\n if (!dragging) return;\n dragging = false;\n gutter.classList.remove('dragging');\n document.body.style.cursor = '';\n document.body.style.userSelect = '';\n });\n})();\n\n// hash routing\nfunction activateStage(n) {\n const cur = Number(n);\n document.querySelectorAll('.stage').forEach(s => s.classList.remove('active'));\n document.querySelector(`[data-stage-pane=\"${cur}\"]`)?.classList.add('active');\n ProjectStore.setStage(cur);\n\n // 圆点状态:< cur → done(森林绿) · = cur → active(橙实心+光晕) · > cur → 默认(浅灰)\n const completed = Math.max(Number(ProjectStore.data.completedStage) || 0, cur - 1);\n document.querySelectorAll('#stage-pill .sp-dot').forEach(s => {\n const i = +s.dataset.stage;\n s.classList.remove('active', 'done');\n if (i === cur) s.classList.add('active');\n else if (i <= completed) s.classList.add('done');\n });\n // 连接线 · idx+1 < cur 时染森林绿\n document.querySelectorAll('#stage-pill .sp-line').forEach((ln, idx) => {\n ln.classList.toggle('done', (idx + 1) <= completed);\n });\n\n // 全高度布局:所有 stage 操作模块 hug content、内容区域 fill content\n // 全部走 flat(像 model-photo 那样的全宽扁平布局,去卡片)\n const contentEl = document.getElementById('page-content');\n if (contentEl) {\n contentEl.classList.add('content--fh');\n contentEl.classList.add('content--fh-flat');\n }\n\n window.scrollTo({ top: 0, behavior: 'smooth' });\n requestAnimationFrame(() => ProjectStore.restoreFields());\n}\nfunction readHash() {\n const m = location.hash.match(/stage-(\\d)/);\n if (m) { activateStage(+m[1]); return; }\n // ?stage=N query 参数也接收\n const q = new URLSearchParams(location.search);\n const s = q.get('stage');\n if (s) { activateStage(+s); return; }\n // 兜底:回到上次离开的 stage,等待生成时离开页面也能继续当前项目进度\n activateStage(ProjectStore.data.currentStage || 1);\n}\nwindow.addEventListener('hashchange', readHash);\nreadHash();\n\n/* ============================================================\n STAGE 1 · 脚本助手 + 镜头脚本 状态驱动\n ============================================================ */\nconst Stage1 = (function () {\n let shots = []; // [{ id, painting, dialog, duration }]\n let chatMsgs = []; // [{ role, html, time }]\n let mode = null;\n let scriptTags = { char: [], scene: [] }; // 自动抓取的人物 / 场景 · 可编辑\n const MODE_LABELS = { ai: 'AI 全生', theme: '一句话主题', manual: '自带脚本' };\n const MODE_USER_COPY = {\n ai: '我用 AI 全生',\n theme: '我用一句话主题',\n manual: '我用自带脚本',\n };\n const BRIEF_OPTIONS = {\n style: ['真实测评', '痛点种草', '小红书种草', '开箱测评', '对比展示'],\n persona: ['通勤敏感肌女生', '熬夜党通勤女性', '学生党女生', '精致宝妈', '成分党用户'],\n };\n function makeBrief(nextMode) {\n if (!nextMode) {\n return {\n source: '未选择',\n style: '待确认',\n persona: '待确认',\n styleNote: '选择脚本来源后由助手推荐',\n personaNote: '选择脚本来源后由助手推荐',\n };\n }\n return {\n source: MODE_LABELS[nextMode] || '未选择',\n style: nextMode === 'theme' ? '痛点种草' : '真实测评',\n persona: '通勤敏感肌女生',\n styleNote: nextMode === 'manual' ? '参考文本识别不足 · 根据商品类目推荐' : '根据商品信息推荐',\n personaNote: '根据商品目标人群推荐',\n };\n }\n let scriptBrief = makeBrief(null);\n\n const $cb = () => document.getElementById('chat-body');\n const $sb = () => document.getElementById('shots-body');\n const $sm = () => document.getElementById('shots-meta');\n const safeHtml = s => String(s || '').replace(/[&<>\"']/g, m => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[m]));\n function renderBriefSummary() {\n const source = document.getElementById('brief-source');\n const style = document.getElementById('brief-style');\n const persona = document.getElementById('brief-persona');\n if (source) source.textContent = scriptBrief.source || '未选择';\n if (style) style.textContent = scriptBrief.style || '待确认';\n if (persona) persona.textContent = scriptBrief.persona || '待确认';\n }\n function briefConfirmHtml(intro) {\n const optionMenu = (kind, current) => `\n <span class=\"script-brief-select\">\n <button class=\"script-brief-value\" type=\"button\" data-brief-act=\"${kind}\" aria-haspopup=\"listbox\" aria-expanded=\"false\" aria-label=\"选择${kind === 'style' ? '脚本风格' : '人物设定'}\">\n <span class=\"v\">${safeHtml(current)}</span>\n </button>\n <span class=\"chip-menu align-right\" role=\"listbox\">\n ${BRIEF_OPTIONS[kind].map(opt => `\n <button class=\"mi${opt === current ? ' selected' : ''}\" type=\"button\" data-brief-pick=\"${kind}\" data-value=\"${safeHtml(opt)}\" role=\"option\" aria-selected=\"${opt === current ? 'true' : 'false'}\">\n <svg class=\"mi-check\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m20 6-11 11-5-5\"/></svg>\n ${safeHtml(opt)}\n </button>`).join('')}\n </span>\n </span>`;\n return `${intro}\n <div class=\"script-brief-card\">\n <div class=\"script-brief-row\">\n <span class=\"k\">风格</span>\n ${optionMenu('style', scriptBrief.style)}\n <span class=\"why\">${safeHtml(scriptBrief.styleNote)}</span>\n </div>\n <div class=\"script-brief-row\">\n <span class=\"k\">人物</span>\n ${optionMenu('persona', scriptBrief.persona)}\n <span class=\"why\">${safeHtml(scriptBrief.personaNote)}</span>\n </div>\n <div class=\"script-brief-actions\">\n <div class=\"action-row\">\n <button class=\"btn btn-ghost\" type=\"button\" data-brief-act=\"reroll\">重新推荐</button>\n <button class=\"btn\" type=\"button\" data-brief-act=\"accept\">确定</button>\n </div>\n </div>\n </div>`;\n }\n function migrateBriefMessages() {\n let changed = false;\n chatMsgs = chatMsgs.map(msg => {\n if (!msg || typeof msg.html !== 'string') return msg;\n if (msg.role === 'ai' && /script-brief-card/.test(msg.html)) {\n const cardIndex = msg.html.indexOf('<div class=\"script-brief-card\"');\n const intro = cardIndex > -1 ? msg.html.slice(0, cardIndex).trim() : '';\n changed = true;\n return {\n ...msg,\n html: briefConfirmHtml(intro || '已更新创作方向。确认后我会按这个方向重写镜头脚本。'),\n };\n }\n if (msg.role === 'user' && msg.html === '保持推荐,生成镜头脚本') {\n changed = true;\n return { ...msg, html: '确定' };\n }\n return msg;\n });\n return changed;\n }\n function tuneBriefByText(text) {\n if (/小红书|种草/.test(text)) scriptBrief.style = '小红书种草';\n else if (/测评|评测/.test(text)) scriptBrief.style = '真实测评';\n else if (/痛点/.test(text)) scriptBrief.style = '痛点种草';\n if (/熬夜/.test(text)) scriptBrief.persona = '熬夜党通勤女性';\n else if (/学生/.test(text)) scriptBrief.persona = '学生党女生';\n else if (/宝妈|妈妈/.test(text)) scriptBrief.persona = '精致宝妈';\n }\n function startScriptGeneration() {\n ProjectStore.startJob('stage1-script', {\n stage: 1,\n label: '脚本初稿生成',\n finishAt: Date.now() + 6500,\n });\n if (!chatMsgs.some(x => /正在解析商品卖点/.test(x.html))) {\n pushMsg('ai', '<span class=\"ai-thinking\">正在解析商品卖点与创作方向 <span class=\"dots\"><span></span><span></span><span></span></span></span>');\n }\n ProjectStore.record('stage1.script.generate', { mode, brief: scriptBrief });\n saveState();\n renderChat();\n window.setTimeout(completeAiJob, 6500);\n }\n function refreshLatestBriefMessage(intro) {\n for (let i = chatMsgs.length - 1; i >= 0; i--) {\n if (chatMsgs[i].role === 'ai' && /script-brief-card/.test(chatMsgs[i].html)) {\n chatMsgs[i].html = briefConfirmHtml(intro);\n return;\n }\n }\n pushMsg('ai', briefConfirmHtml(intro));\n }\n function handleBriefPick(kind, value) {\n if (!value) return;\n if (kind === 'style') {\n scriptBrief.style = value;\n scriptBrief.styleNote = '已手动选择';\n } else if (kind === 'persona') {\n scriptBrief.persona = value;\n scriptBrief.personaNote = '已手动选择';\n }\n refreshLatestBriefMessage('已更新创作方向。确认后我会按这个方向重写镜头脚本。');\n ProjectStore.record('stage1.brief.option.selected', { kind, value });\n saveState();\n renderChat();\n }\n function handleBriefAction(action, trigger) {\n if (action === 'accept') {\n pushMsg('user', '确定');\n startScriptGeneration();\n } else if (action === 'reroll') {\n scriptBrief = {\n ...scriptBrief,\n style: scriptBrief.style === '真实测评' ? '痛点种草' : '真实测评',\n persona: scriptBrief.persona === '通勤敏感肌女生' ? '熬夜党通勤女性' : '通勤敏感肌女生',\n styleNote: '已根据商品卖点重新推荐',\n personaNote: '已根据使用场景重新推荐',\n };\n pushMsg('ai', briefConfirmHtml('我重新给你配了一组创作方向,确认后就生成镜头脚本。'));\n saveState();\n renderChat();\n } else if (action === 'style') {\n const box = trigger?.closest('.script-brief-select');\n if (!box) return;\n document.querySelectorAll('.script-brief-select.open').forEach(el => { if (el !== box) el.classList.remove('open'); });\n box.classList.toggle('open');\n trigger.setAttribute('aria-expanded', box.classList.contains('open') ? 'true' : 'false');\n } else if (action === 'persona') {\n const box = trigger?.closest('.script-brief-select');\n if (!box) return;\n document.querySelectorAll('.script-brief-select.open').forEach(el => { if (el !== box) el.classList.remove('open'); });\n box.classList.toggle('open');\n trigger.setAttribute('aria-expanded', box.classList.contains('open') ? 'true' : 'false');\n }\n }\n\n /* 自动从脚本(painting + dialog 文本)抽取人物 / 场景关键词 · 白名单匹配 */\n const CHAR_KEYWORDS = ['女主', '男主', '同事', '闺蜜', '男友', '女友', '妈妈', '爸爸', '老师', '同学', '朋友', '路人', '主播', '老板'];\n const SCENE_KEYWORDS = ['书桌', '卫生间', '床头', '化妆台', '办公桌', '客厅', '厨房', '卧室', '阳台', '电梯', '咖啡店', '公司', '镜前', '桌面', '会议室', '车里', '公园', '商场'];\n function extractScriptTags() {\n const text = shots.map(s => (s.painting || '') + ' ' + (s.dialog || '')).join(' ');\n const dedup = arr => Array.from(new Set(arr));\n const exist = { char: new Set(scriptTags.char), scene: new Set(scriptTags.scene) };\n const newChar = CHAR_KEYWORDS.filter(k => text.includes(k) && !exist.char.has(k));\n const newScene = SCENE_KEYWORDS.filter(k => text.includes(k) && !exist.scene.has(k));\n scriptTags.char = dedup([...scriptTags.char, ...newChar]);\n scriptTags.scene = dedup([...scriptTags.scene, ...newScene]);\n }\n function renderScriptTags() {\n ['char', 'scene'].forEach(kind => {\n const group = document.querySelector(`.script-tags .tag-group[data-kind=\"${kind}\"]`);\n if (!group) return;\n const addBtn = group.querySelector('.tag-add');\n group.querySelectorAll('.script-tag').forEach(el => el.remove());\n scriptTags[kind].forEach((name, i) => {\n const chip = document.createElement('span');\n chip.className = 'script-tag';\n chip.innerHTML = `<span class=\"t\" contenteditable=\"true\" spellcheck=\"false\" data-i=\"${i}\">${name}</span><button class=\"x\" type=\"button\" aria-label=\"删除\">×</button>`;\n group.insertBefore(chip, addBtn);\n });\n group.querySelectorAll('.script-tag').forEach((chip, i) => {\n const t = chip.querySelector('.t');\n const x = chip.querySelector('.x');\n x.addEventListener('click', (e) => { e.stopPropagation(); scriptTags[kind].splice(i, 1); saveState(); renderScriptTags(); });\n t.addEventListener('blur', () => {\n const v = (t.textContent || '').trim();\n if (!v) { scriptTags[kind].splice(i, 1); renderScriptTags(); }\n else { scriptTags[kind][i] = v; }\n saveState();\n });\n t.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); t.blur(); } });\n });\n });\n }\n function bindTagAdders() {\n document.querySelectorAll('.script-tags .tag-add').forEach(btn => {\n btn.addEventListener('click', () => {\n const kind = btn.parentElement.dataset.kind;\n scriptTags[kind].push('');\n saveState();\n renderScriptTags();\n const group = document.querySelector(`.script-tags .tag-group[data-kind=\"${kind}\"]`);\n const chips = group.querySelectorAll('.script-tag .t');\n const last = chips[chips.length - 1];\n if (last) {\n last.focus();\n const sel = window.getSelection();\n const range = document.createRange();\n range.selectNodeContents(last);\n sel.removeAllRanges(); sel.addRange(range);\n }\n });\n });\n }\n\n function now() {\n const d = new Date();\n return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');\n }\n function pushMsg(role, html) { chatMsgs.push({ role, html, time: now() }); }\n function saveState() {\n ProjectStore.saveStage('stage1', { shots, chatMsgs, mode, scriptTags, scriptBrief });\n renderBriefSummary();\n }\n function loadState() {\n const saved = ProjectStore.data.stage1;\n if (!saved) { renderBriefSummary(); return; }\n if (Array.isArray(saved.shots)) shots = saved.shots;\n if (Array.isArray(saved.chatMsgs)) chatMsgs = saved.chatMsgs;\n if (saved.mode) mode = saved.mode;\n if (saved.scriptBrief && typeof saved.scriptBrief === 'object') scriptBrief = { ...scriptBrief, ...saved.scriptBrief };\n else if (mode) scriptBrief = makeBrief(mode);\n if (saved.scriptTags && Array.isArray(saved.scriptTags.char) && Array.isArray(saved.scriptTags.scene)) {\n scriptTags = saved.scriptTags;\n }\n if (migrateBriefMessages()) {\n ProjectStore.saveStage('stage1', { shots, chatMsgs, mode, scriptTags, scriptBrief });\n }\n renderBriefSummary();\n }\n function getDefaultDraft() {\n return [\n { id: 'sh1', painting: '中景慢推 · 深夜居家书桌全景。屏幕仍亮着 PPT,女主背影瘫在椅子上,屏幕冷光 + 台灯暖光对比。字幕\"凌晨 02:14\"淡入。', dialog: '(无台词 · BGM 渐起)', duration: 5 },\n { id: 'sh2', painting: '近景 · 卫生间镜前。女主低头看脸,T 区起皮、暗沉特写,冷白灯偏惨。', dialog: '\"做完这版稿又是凌晨两点……(叹气)脸已经不能看了。\"', duration: 5 },\n { id: 'sh3', painting: '俯拍特写 · 回到书桌,拉开抽屉。囤好的透真补水面膜露半角,手伸进去抽出一片。', dialog: '\"还好抽屉里囤了透真玻尿酸面膜。\"', duration: 5 },\n { id: 'sh4', painting: '桌面微距特写 · 撕开锡纸包装的瞬间。30g 厚精华液缓缓滴落,面膜布展开,质地拉丝可见。', dialog: '\"30g 一片,精华液比普通面膜厚整整三倍。\"', duration: 6 },\n { id: 'sh5', painting: '床头近景 · 女主敷好面膜闭眼躺下,台灯暖光打在脸侧。膜布贴合脸型,边缘服帖。', dialog: '\"贴上去那一瞬间 —— 凉凉的,像把皮肤泡了一次澡。\"', duration: 6 },\n { id: 'sh6', painting: '中景 · 第二天清晨化妆台。阳光透过窗帘,女主对镜上妆,皮肤透亮、粉底服帖。同事画外音\"你最近用啥了\"。', dialog: '\"第二天脸是软的,粉底都不卡了。同事都跑来问。\"', duration: 8 },\n { id: 'sh7', painting: '平铺俯拍 · 桌面五片装产品 + 单片包装。价格 \"618 · 5 片 ¥39.9\" 弹出,购物车图标右下角浮现。', dialog: '\"618 五片 39.9,自用送人都合适。链接放评论区。\"', duration: 5 },\n ];\n }\n function completeAiJob() {\n const existing = new Set(shots.map(s => s.id));\n getDefaultDraft().forEach(s => {\n if (!existing.has(s.id)) shots.push(s);\n });\n chatMsgs = chatMsgs.filter(x => !(x.role === 'ai' && /ai-thinking/.test(x.html)));\n if (!chatMsgs.some(x => x.html.includes('初稿完成'))) {\n pushMsg('ai', '初稿完成。点击任意卡片文字可直接编辑;鼠标移到卡片之间会出现「+ 添加分镜」。');\n }\n ProjectStore.finishJob('stage1-script');\n ProjectStore.record('stage1.script.ready', { shots: shots.length });\n saveState();\n renderChat();\n renderShots();\n }\n function resumeAiJobIfNeeded() {\n const job = ProjectStore.getJob('stage1-script');\n if (!job || job.status !== 'running') return;\n const remaining = Math.max(0, (job.finishAt || Date.now()) - Date.now());\n if (!chatMsgs.some(x => /ai-thinking/.test(x.html))) {\n pushMsg('ai', '<span class=\"ai-thinking\">脚本生成仍在后台排队 <span class=\"dots\"><span></span><span></span><span></span></span></span>');\n }\n saveState();\n renderChat();\n window.setTimeout(completeAiJob, remaining);\n }\n\n function renderChat() {\n const body = $cb(); if (!body) return;\n if (chatMsgs.length === 0 && !mode) {\n body.innerHTML = `<div class=\"chat-empty\">\n <div class=\"ce-title\">选择一种生成方式开始</div>\n <div class=\"ce-hint\">// 三种,由「最省事」到「最保真原意」</div>\n <div class=\"chat-modes\">\n <button class=\"chat-mode primary\" data-mode=\"ai\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z\"/></svg>AI 全生</button>\n <button class=\"chat-mode\" data-mode=\"theme\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 18h6\"/><path d=\"M10 22h4\"/><path d=\"M15 14a4.65 4.65 0 0 0 1.4-2.5A6 6 0 1 0 6 8c0 1 .23 2.23 1.5 3.5\"/></svg>一句话主题</button>\n <button class=\"chat-mode\" data-mode=\"manual\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\"/><path d=\"M14 2v6h6\"/></svg>自带脚本</button>\n </div>\n </div>`;\n body.querySelectorAll('.chat-mode').forEach(btn => {\n btn.addEventListener('click', () => pickMode(btn.dataset.mode));\n });\n return;\n }\n body.innerHTML = chatMsgs.map(msg => {\n if (msg.role === 'ai') {\n return `<div class=\"msg ai\"><div style=\"display:flex; gap:10px; align-items:flex-start;\"><div class=\"ai-avatar\" style=\"margin-top:2px;\">AI</div><div class=\"bubble\">${msg.html}</div></div><div class=\"time\" style=\"margin-left:36px;\">${msg.time}</div></div>`;\n }\n return `<div class=\"msg user\"><div class=\"bubble\">${msg.html}</div><div class=\"time\">${msg.time}</div></div>`;\n }).join('');\n body.querySelectorAll('[data-brief-act]').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n handleBriefAction(btn.dataset.briefAct, btn);\n });\n });\n body.querySelectorAll('[data-brief-pick]').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n handleBriefPick(btn.dataset.briefPick, btn.dataset.value);\n });\n });\n body.scrollTop = body.scrollHeight;\n }\n\n function renderShots() {\n const body = $sb(); if (!body) return;\n const meta = $sm();\n const phMeta = document.getElementById('page-head-shots-meta'); // 头部已移除,可能为 null · 下方写入全程 ?. 防御\n if (shots.length === 0) {\n body.innerHTML = `<div class=\"shots-empty\">\n <div class=\"empty-ico\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"5\" width=\"18\" height=\"14\" rx=\"2\"/><path d=\"M3 10h18M9 5v14\"/></svg></div>\n <div class=\"empty-title\">还没有镜头脚本</div>\n <div class=\"empty-hint\">// 跟右侧脚本助手对话<br>选择一种方式生成你的第一稿</div>\n </div>`;\n if (meta) meta.textContent = '· 空 · 待生成';\n if (phMeta) phMeta.textContent = '待生成镜头脚本';\n renderScriptTags();\n return;\n }\n let cum = 0;\n let html = '';\n shots.forEach((s, i) => {\n const start = cum;\n cum += (s.duration || 5);\n const tlabel = start + '-' + cum + 's';\n html += `<div class=\"shot-card\" data-id=\"${s.id}\">\n <div class=\"shot-head\">\n <div class=\"shot-num\">${i + 1}</div>\n <div class=\"shot-time\">${tlabel}</div>\n <span class=\"spacer\"></span>\n <button class=\"icon-mini-btn\" title=\"重写本场\" data-act=\"regen\" data-id=\"${s.id}\">↻</button>\n <button class=\"icon-mini-btn\" title=\"删除本场\" data-act=\"del\" data-id=\"${s.id}\">×</button>\n </div>\n <div class=\"shot-row\"><span class=\"shot-k\">画面</span><div class=\"shot-v\" contenteditable=\"true\" data-field=\"painting\" data-placeholder=\"(画面必填)点击编辑\"${s.painting ? '' : ' data-empty=\"true\"'}>${s.painting || ''}</div></div>\n <div class=\"shot-row\"><span class=\"shot-k\">对白</span><div class=\"shot-v\" contenteditable=\"true\" data-field=\"dialog\" data-placeholder=\"(对白可空)点击编辑\"${s.dialog ? '' : ' data-empty=\"true\"'}>${s.dialog || ''}</div></div>\n </div>`;\n // 每张卡片后都跟一个 gap(包括最后一张),允许在任意位置 hover 加分镜\n html += `<div class=\"shot-insert-gap\" data-after=\"${s.id}\"><button class=\"add-shot-btn\" data-act=\"add-here\" data-after=\"${s.id}\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 5v14M5 12h14\"/></svg>添加分镜</button></div>`;\n });\n body.innerHTML = html;\n if (meta) meta.textContent = '· ' + shots.length + ' 镜 · 0-' + cum + 's';\n if (phMeta) phMeta.textContent = shots.length + ' 镜 · 0-' + cum + 's';\n body.querySelectorAll('.shot-v[contenteditable]').forEach(el => {\n el.addEventListener('focus', () => { el.dataset.empty = 'false'; });\n el.addEventListener('blur', () => {\n const card = el.closest('.shot-card');\n const id = card.dataset.id;\n const field = el.dataset.field;\n const v = el.textContent.trim();\n const s = shots.find(x => x.id === id);\n if (s) s[field] = v;\n if (!v) el.dataset.empty = 'true';\n ProjectStore.record('stage1.shot.edited', { id, field });\n saveState();\n });\n });\n body.querySelectorAll('[data-act]').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n const act = btn.dataset.act;\n const id = btn.dataset.id;\n const after = btn.dataset.after;\n if (act === 'del') {\n shots = shots.filter(x => x.id !== id);\n ProjectStore.record('stage1.shot.deleted', { id });\n saveState();\n renderShots();\n } else if (act === 'regen') {\n Shell.toast('已请求重写本场', '↻ shot-' + id);\n ProjectStore.record('stage1.shot.regen', { id });\n } else if (act === 'add-here') {\n const idx = shots.findIndex(x => x.id === after);\n shots.splice(idx + 1, 0, { id: 'sh' + Date.now(), painting: '', dialog: '', duration: 5 });\n ProjectStore.record('stage1.shot.added', { after });\n saveState();\n renderShots();\n }\n });\n });\n // 镜头有变,刷新人物/场景标签(增量抽取 · 已有的保留)\n extractScriptTags();\n renderScriptTags();\n }\n\n function pickMode(m) {\n mode = m;\n scriptBrief = makeBrief(m);\n ProjectStore.record('stage1.mode.selected', { mode: m });\n pushMsg('user', MODE_USER_COPY[m] || '选择脚本来源');\n if (m === 'ai') {\n pushMsg('ai', briefConfirmHtml('我会根据商品信息直接生成第一版。生成前先确认创作方向。'));\n } else if (m === 'theme') {\n pushMsg('ai', '好,请给我一句话主题(5-30 字),例如:<br>· 熬夜党的急救面膜<br>· 加班吃啥不内疚<br>我会先根据这句话补全风格和人物设定,再让你确认。');\n } else if (m === 'manual') {\n pushMsg('ai', '好,把你的脚本(旁白 / 镜头描述均可)粘贴到下面输入框。我会先识别风格和人物设定;识别不到的部分,再用商品信息补齐。');\n }\n saveState();\n renderChat();\n }\n\n function init() {\n loadState();\n renderChat();\n renderShots();\n resumeAiJobIfNeeded();\n ProjectStore.restoreFields();\n document.getElementById('chat-clear-btn')?.addEventListener('click', () => {\n chatMsgs = []; mode = null; shots = []; scriptTags = { char: [], scene: [] };\n scriptBrief = makeBrief(null);\n ProjectStore.clearJob('stage1-script');\n ProjectStore.record('stage1.cleared');\n saveState();\n renderChat(); renderShots();\n });\n bindTagAdders();\n document.getElementById('chat-regen-btn')?.addEventListener('click', () => {\n Shell.toast('已请求整体重写', 'POST /script/regen');\n });\n document.addEventListener('click', (e) => {\n if (e.target.closest('.script-brief-select')) return;\n document.querySelectorAll('.script-brief-select.open').forEach(el => el.classList.remove('open'));\n });\n const sendBtn = document.getElementById('chat-send-btn');\n const ta = document.getElementById('chat-textarea');\n const attachRow = document.getElementById('chat-attach-row');\n let attachments = [];\n const renderAttach = () => {\n if (!attachRow) return;\n if (!attachments.length) { attachRow.hidden = true; attachRow.innerHTML = ''; return; }\n attachRow.hidden = false;\n attachRow.innerHTML = attachments.map((f, i) => `\n <span class=\"chat-attach-chip\">\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\"/><path d=\"M14 2v6h6\"/></svg>\n ${f.name.replace(/</g, '<')}\n <button class=\"x\" data-rm=\"${i}\" aria-label=\"移除\">\n <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M18 6L6 18M6 6l12 12\"/></svg>\n </button>\n </span>`).join('');\n attachRow.querySelectorAll('button[data-rm]').forEach(b => {\n b.addEventListener('click', () => {\n attachments.splice(+b.dataset.rm, 1);\n renderAttach();\n });\n });\n };\n const upBtn = document.getElementById('chat-upload-btn');\n const upInput = document.getElementById('chat-upload-input');\n if (upBtn && upInput) {\n upBtn.addEventListener('click', () => upInput.click());\n upInput.addEventListener('change', (e) => {\n const files = Array.from(e.target.files || []);\n if (!files.length) return;\n attachments.push(...files);\n renderAttach();\n Shell.toast('已附加脚本文件', files.map(f => f.name).join('、'));\n upInput.value = '';\n });\n }\n if (sendBtn && ta) {\n const send = () => {\n const v = ta.value.trim();\n if (!v && !attachments.length) return;\n const fileTags = attachments.length\n ? `<div class=\"hstack\" style=\"gap:6px; flex-wrap:wrap; margin-bottom:6px;\">${attachments.map(f => `<span class=\"pill\" style=\"font-family:var(--font-mono); font-size:10.5px;\">📎 ${f.name.replace(/</g, '<')}</span>`).join('')}</div>`\n : '';\n pushMsg('user', fileTags + (v ? v.replace(/</g, '<') : '<span class=\"muted-2\">(已附加文件)</span>'));\n const fileCt = attachments.length;\n ta.value = '';\n attachments = []; renderAttach();\n ProjectStore.record('stage1.chat.sent', { hasText: !!v, files: fileCt });\n saveState();\n renderChat();\n setTimeout(() => {\n const directionIntent = /小红书|种草|测评|评测|痛点|熬夜|学生|宝妈|妈妈|人物|风格|口吻|换成/.test(v);\n if (mode && (!shots.length || directionIntent)) {\n tuneBriefByText(v);\n const intro = directionIntent\n ? '已更新创作方向。确认后我会按这个方向重写镜头脚本。'\n : '我先根据你给的内容补全创作方向,确认后再生成镜头脚本。';\n pushMsg('ai', briefConfirmHtml(intro));\n } else {\n pushMsg('ai', '收到。我会按这个方向调整脚本(静态演示;实际接 LLM API)。');\n }\n saveState();\n renderChat();\n }, 400);\n };\n sendBtn.addEventListener('click', send);\n ta.addEventListener('keydown', (e) => {\n if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); send(); }\n });\n }\n // 顶部「+ 追加一场」按钮已移除 — 添加分场改为卡片间 hover 时出现「+ 添加分场」\n }\n\n return { init };\n})();\nStage1.init();\n\n/* ============================================================\n STAGE 2 · 基础资产 · 锚点 + 横滑预设 + 详情/库 modal\n ============================================================ */\nconst Stage2 = (function () {\n const MODEL_LIB = [\n { id: 'm1', name: '清新短发女', sub: '通勤白领',\n gender: '女', age: '青年', region: '东亚', skin: '白皙', height: '中等', build: '纤细',\n hairLen: '短发', hairColor: '黑色', vibe: '清新', feature: '邻家气质 · 微笑亲和' },\n { id: 'm2', name: '甜美长发女', sub: '学生党',\n gender: '女', age: '青年', region: '东亚', skin: '白皙', height: '偏小', build: '纤细',\n hairLen: '长发', hairColor: '深棕', vibe: '甜美', feature: '校园风 · 书卷气重' },\n { id: 'm3', name: '商务套装男', sub: '总裁 IP',\n gender: '男', age: '中年', region: '东亚', skin: '健康', height: '偏高', build: '标准',\n hairLen: '短发', hairColor: '黑色', vibe: '稳重', feature: '商务精英范 · 西装常驻' },\n { id: 'm4', name: '宝妈居家女', sub: '家庭决策',\n gender: '女', age: '中年', region: '东亚', skin: '白皙', height: '中等', build: '标准',\n hairLen: '中发', hairColor: '棕黑', vibe: '温柔', feature: '居家氛围 · 决策力强' },\n { id: 'm5', name: '运动健身女', sub: '健身博主',\n gender: '女', age: '青年', region: '东亚', skin: '健康', height: '偏高', build: '运动',\n hairLen: '中发', hairColor: '栗色', vibe: '活力', feature: '马尾 · 健身房常客' },\n { id: 'm6', name: '少年学生男', sub: 'Z 世代',\n gender: '男', age: '青年', region: '东亚', skin: '白皙', height: '中等', build: '纤细',\n hairLen: '短发', hairColor: '黑色', vibe: '阳光', feature: '校服感 · 朝气十足' },\n ];\n const SCENE_LIB = [\n { id: 's1', name: '日系卧室', sub: '居家温柔' },\n { id: 's2', name: '咖啡厅工位', sub: '通勤场景' },\n { id: 's3', name: '梳妆台', sub: '美妆个护' },\n { id: 's4', name: '健身房', sub: '运动场景' },\n { id: 's5', name: '厨房料理台', sub: '家居家电' },\n { id: 's6', name: '日落天台', sub: '氛围户外' },\n ];\n\n // 商品名 / 项目名已提升到 page 顶层 script(see CURRENT_PRODUCT_NAME / PROJECT_TITLE)\n // 此处通过闭包引用即可\n\n const ASSET_DETAILS = {\n 'ch-linxi': { kind: 'character', title: '林夕 · 都市白领', hasTri: true, info: [['类别', '人物 · 主角'], ['年龄', '25-30'], ['服装', '宽松米色家居服'], ['妆面', '日常裸妆 · 略疲倦'], ['用途', '主角出镜 · 痛点共鸣'], ['状态', '已确认']] },\n 'ch-anan': { kind: 'character', title: '阿楠 · 朋友/同事', hasTri: false, info: [['类别', '人物 · 对照角色'], ['年龄', '25-30'], ['服装', '白色衬衫 / 精致'], ['用途', '对照出镜'], ['状态', '生成中']] },\n 'sc-desk': { kind: 'scene', title: '深夜办公桌', info: [['类别', '场景 · 室内'], ['光线', '台灯暖光 · 屏幕冷光'], ['用途', '镜 1 痛点'], ['状态', '已确认']] },\n 'sc-bed': { kind: 'scene', title: '卧室床头', info: [['类别', '场景 · 室内'], ['光线', '夜灯暖光'], ['用途', '镜 4 敷膜'], ['状态', '已确认']] },\n 'sc-subway': { kind: 'scene', title: '通勤地铁', info: [['类别', '场景 · 室内'], ['用途', '镜 5 对照'], ['状态', '失败 · 待重跑']] },\n 'prod-main': { kind: 'product', title: CURRENT_PRODUCT_NAME, hasTri: false, info: [['类别', '商品 · 当前项目'], ['名称', CURRENT_PRODUCT_NAME], ['三视图', '待生成'], ['状态', '缺三视图']] },\n };\n\n // ─── 统一详情面板渲染 ───\n function _hashCode(s) { let h = 0; for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0; return Math.abs(h); }\n function _fmtAssetId(name, kind) {\n const seed = _hashCode(name);\n const code = String(seed % 1000).padStart(3, '0');\n return 'ASSET-20240520-' + (kind === 'model' ? 'M' : kind === 'scene' ? 'S' : 'P') + code;\n }\n function _fmtFileSize(name) { const seed = _hashCode(name); return (4 + (seed % 100) / 10).toFixed(1) + 'MB'; }\n function _fmtFavCt(name) { return String(8 + _hashCode(name) % 80); }\n function _fmtDlCt(name) { const n = 200 + _hashCode(name) % 1800; return (n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n)); }\n\n function renderAssetDetail(payload) {\n // payload: { title, tagText, leadLabel, kind ('actor'|'scene'|'product'), ratio, intro, tags[], thumbs[], props[], hasTri, missingTriHint, applyLabel, ownPortraits[], ownTriVersions[] }\n document.getElementById('asset-detail-title').textContent = payload.title;\n document.getElementById('asset-detail-kind').textContent = '/ ' + payload.tagText;\n\n // 主立绘 · 用户上传有 portraits[0].url 时显示真实图片;否则占位\n const leadEl = document.getElementById('asset-detail-lead-img');\n // 把当前主图 src/name 挂在 leadEl 的 dataset 上,zoom 按钮读 dataset 始终拿到最新值\n function _adSetLead(p) {\n if (!leadEl) return;\n const old = leadEl.querySelector('img.ad-lead-pic');\n if (old) old.remove();\n const ph = leadEl.querySelector('.ph-frame');\n if (p && p.url) {\n if (ph) ph.style.display = 'none';\n const img = document.createElement('img');\n img.className = 'ad-lead-pic'; img.src = p.url; img.alt = p.name || payload.title;\n img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;';\n leadEl.appendChild(img);\n leadEl.dataset.curSrc = p.url; leadEl.dataset.curName = p.name || payload.title;\n } else {\n if (ph) { ph.style.display = ''; ph.textContent = (p && p.label) || payload.leadLabel || payload.title; }\n leadEl.dataset.curSrc = ''; leadEl.dataset.curName = (p && p.label) || payload.leadLabel || payload.title;\n }\n }\n leadEl.innerHTML = `<span class=\"ph-frame\">${payload.leadLabel || payload.title}</span>`;\n // 立绘 zoom 按钮 → 打开 lightbox(单次绑定,值从 dataset 读)\n const _leadZoomBtn = document.getElementById('asset-detail-zoom-btn');\n if (_leadZoomBtn && !_leadZoomBtn.dataset.bound) {\n _leadZoomBtn.dataset.bound = '1';\n _leadZoomBtn.addEventListener('click', e => {\n e.stopPropagation();\n if (window.Shell?._openLightbox) Shell._openLightbox(leadEl.dataset.curSrc || '', leadEl.dataset.curName || '');\n });\n }\n\n // 缩略图 strip · 用户上传有 ownPortraits 数组 → 显示多张真实图;平台预设 → 仅 1 张占位\n const ownPortraits = (payload.ownPortraits && payload.ownPortraits.length) ? payload.ownPortraits : null;\n const thumbs = ownPortraits || (payload.thumbs && payload.thumbs.length ? payload.thumbs.map(t => ({ label: t })) : [{ label: payload.kind === 'scene' ? '场景' : '立绘' }]);\n const thumbsEl = document.getElementById('asset-detail-thumbs');\n thumbsEl.innerHTML = thumbs.map((p, i) => {\n const inner = p.url\n ? `<img src=\"${p.url}\" alt=\"${(p.name||'').replace(/\"/g,'"')}\" style=\"width:100%;height:100%;object-fit:cover;display:block;\">`\n : `<span class=\"ph-frame\">${p.label || ('v'+(i+1))}</span>`;\n return `<div class=\"thumb placeholder${i === 0 ? ' active' : ''}\" data-idx=\"${i}\">${inner}</div>`;\n }).join('');\n _adSetLead(thumbs[0]);\n thumbsEl.querySelectorAll('.thumb').forEach(t => t.addEventListener('click', () => {\n thumbsEl.querySelectorAll('.thumb').forEach(x => x.classList.remove('active'));\n t.classList.add('active');\n _adSetLead(thumbs[+t.dataset.idx]);\n }));\n\n // 三视图区\n const tri = document.getElementById('asset-detail-tri');\n const triSection = document.getElementById('asset-detail-tri-section');\n const tip = document.getElementById('asset-detail-tip');\n const ratioChip = document.getElementById('asset-detail-ratio');\n const ownTri = (payload.ownTriVersions && payload.ownTriVersions.length) ? payload.ownTriVersions : null;\n if (payload.kind === 'scene') {\n triSection.style.display = 'none';\n } else if (payload.kind === 'actor') {\n triSection.style.display = '';\n tri.style.display = '';\n tri.classList.remove('actor');\n ratioChip.textContent = '16:9';\n tip.style.display = 'none';\n const _zoomBtn = `<button class=\"ad-zoom-btn\" type=\"button\" data-zoom-tri aria-label=\"查看大图\" title=\"查看大图\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5\"/></svg></button>`;\n if (ownTri) {\n const cur = ownTri[ownTri.length - 1];\n const stripHtml = ownTri.map((v, i) => `<div class=\"v-thumb${i === ownTri.length - 1 ? ' active' : ''}\" data-idx=\"${i}\" title=\"${v.label} · ${v.ts}\"><span class=\"v\">${v.label}</span></div>`).join('');\n tri.innerHTML = `\n <div class=\"placeholder\"><span class=\"ph-frame\" id=\"ad-tri-main-lbl\">${payload.title} · ${cur.label} · ${cur.ts}</span>${_zoomBtn}</div>\n <div class=\"md-view-versions\" style=\"margin-top:8px;\">${stripHtml}</div>`;\n const mainLbl = tri.querySelector('#ad-tri-main-lbl');\n tri.querySelectorAll('.v-thumb').forEach(t => t.addEventListener('click', () => {\n tri.querySelectorAll('.v-thumb').forEach(x => x.classList.remove('active'));\n t.classList.add('active');\n const v = ownTri[+t.dataset.idx];\n if (mainLbl) mainLbl.textContent = `${payload.title} · ${v.label} · ${v.ts}`;\n }));\n } else {\n tri.innerHTML = `<div class=\"placeholder\"><span class=\"ph-frame\">${payload.title} · 三视图 (正/侧/背)</span>${_zoomBtn}</div>`;\n }\n tri.querySelector('[data-zoom-tri]')?.addEventListener('click', e => {\n e.stopPropagation();\n const lbl = tri.querySelector('#ad-tri-main-lbl')?.textContent || (payload.title + ' · 三视图');\n if (window.Shell?._openLightbox) Shell._openLightbox('', lbl);\n });\n } else {\n // product\n triSection.style.display = '';\n tri.style.display = '';\n tri.classList.remove('actor');\n ratioChip.textContent = '16:9';\n if (payload.hasTri !== false) {\n tri.innerHTML = `<div class=\"placeholder\"><span class=\"ph-frame\">${payload.title} · 三视图</span><button class=\"ad-zoom-btn\" type=\"button\" aria-label=\"查看大图\" title=\"查看大图\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5\"/></svg></button></div>`;\n tip.style.display = 'none';\n tri.querySelector('.ad-zoom-btn')?.addEventListener('click', e => {\n e.stopPropagation();\n if (window.Shell?._openLightbox) Shell._openLightbox('', payload.title + ' · 三视图');\n });\n } else {\n tri.innerHTML = `<div class=\"placeholder missing\" data-tri=\"0\">\n <svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 8v4M12 16h.01\"/></svg>\n <span>${payload.missingTriHint || '暂未生成三视图(16:9 单图)'}</span>\n </div>`;\n tip.style.display = 'flex';\n }\n }\n\n // 简介\n document.getElementById('asset-detail-intro').textContent = payload.intro || '暂无简介';\n\n // 标签 chips\n const tags = payload.tags && payload.tags.length ? payload.tags : [];\n document.getElementById('asset-detail-tags').innerHTML = tags.map(t => `<span class=\"ad-tag-chip\">${t}</span>`).join('')\n + `<button class=\"ad-tag-add\" type=\"button\" title=\"添加标签\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"><path d=\"M8 3v10M3 8h10\"/></svg></button>`;\n\n // 属性表 (3 列 × N 行 grid)\n const props = payload.props || [];\n document.getElementById('asset-detail-props').innerHTML = props\n .map(([k, v]) => `<div class=\"ad-prop\"><span class=\"k\">${k}</span><span class=\"v\">${v}</span></div>`)\n .join('');\n\n // apply 按钮文案 (默认「使用该资产」)\n const applyBtn = document.getElementById('asset-detail-apply-btn');\n if (applyBtn) applyBtn.textContent = payload.applyLabel || '使用该资产';\n\n document.getElementById('asset-detail-modal').classList.add('show');\n }\n\n function openStripDetail(name, sub, kind) {\n // 优先查用户上传:演员 MODEL_OWN / 场景 SCENE_OWN(带 portraits + triVersions)\n const ownArr = kind === 'model' ? MODEL_OWN : SCENE_OWN;\n const ownItem = ownArr.find(x => x.name === name) || null;\n const isOwn = !!ownItem;\n const sourceLabel = isOwn ? '我的上传' : '平台预设';\n\n if (kind === 'model') {\n const actor = !isOwn ? MODEL_LIB.find(x => x.name === name) : null;\n const a = actor || { gender: '女', age: '青年', region: '东亚', skin: '白皙', height: '中等',\n build: '标准', hairLen: '中发', hairColor: '黑色', vibe: '清新', feature: sub || (isOwn ? '我的上传演员' : '预设演员') };\n const tags = [a.vibe, a.age, a.hairLen, a.region, a.skin].filter(Boolean);\n const props = [\n ['性别', a.gender], ['种族', a.region], ['作品ID', _fmtAssetId(name, 'model')],\n ['年龄段', a.age], ['气质', a.vibe], ['创作人', 'Airshelf'],\n ['身高', a.height], ['体格', a.build], ['文件大小', _fmtFileSize(name)],\n ['发型', a.hairLen + ' · ' + a.hairColor], ['来源', sourceLabel], ['发布时间', '2024-05-20'],\n ];\n renderAssetDetail({\n title: name, tagText: '人物 · ' + (isOwn ? '我的演员' : '预设演员'), leadLabel: name + ' · 立绘',\n kind: 'actor', intro: a.feature || sub || '',\n tags, props, applyLabel: '应用到当前项目',\n ownPortraits: isOwn ? ownItem.portraits : null,\n ownTriVersions: isOwn ? ownItem.triVersions : null,\n });\n } else {\n const props = [\n ['类别', '场景 · ' + (isOwn ? '我的上传' : '预设')], ['标签', sub || '-'], ['作品ID', _fmtAssetId(name, 'scene')],\n ['来源', sourceLabel], ['用途', '本项目场景资产'], ['创作人', 'Airshelf'],\n ['镜头', '通用'], ['光线', '自然光'], ['文件大小', _fmtFileSize(name)],\n ];\n renderAssetDetail({\n title: name, tagText: '场景 · ' + (isOwn ? '我的场景' : '预设'), leadLabel: name + ' · 主图',\n kind: 'scene', intro: sub || '场景资产',\n tags: [sub].filter(Boolean), props, applyLabel: '应用到当前项目',\n ownPortraits: isOwn ? ownItem.portraits : null,\n });\n }\n }\n\n function renderStrip(containerId, items, kind) {\n const el = document.getElementById(containerId);\n if (!el) return;\n el.innerHTML = items.map(it => `<div class=\"asset-card-2\" data-strip-kind=\"${kind}\" data-strip-id=\"${it.id}\" data-strip-name=\"${it.name}\" data-strip-sub=\"${it.sub}\">\n <div class=\"placeholder thumb-2\"><span class=\"ph-frame\">${it.name}</span></div>\n <div class=\"body-2\">\n <div class=\"hstack\"><strong style=\"font-size:13.5px;\">${it.name}</strong><span class=\"spacer\"></span><span class=\"pill info\" style=\"font-size:10.5px;\">${it.sub}</span></div>\n </div>\n </div>`).join('');\n el.querySelectorAll('.asset-card-2').forEach(card => {\n card.addEventListener('click', () => {\n const name = card.dataset.stripName;\n const sub = card.dataset.stripSub;\n openStripDetail(name, sub, kind);\n });\n });\n }\n\n function openDetail(id) {\n const d = ASSET_DETAILS[id];\n if (!d) return;\n const kindMap = { character: 'actor', scene: 'scene', product: 'product' };\n const kindLabelMap = { character: '人物', scene: '场景', product: '商品' };\n const baseProps = d.info.slice();\n baseProps.push(['作品ID', _fmtAssetId(d.title, d.kind === 'character' ? 'model' : d.kind === 'scene' ? 'scene' : 'product')]);\n baseProps.push(['创作人', 'Airshelf']);\n baseProps.push(['文件大小', _fmtFileSize(d.title)]);\n baseProps.push(['发布时间', '2024-05-20']);\n renderAssetDetail({\n title: d.title,\n tagText: kindLabelMap[d.kind] + (d.kind === 'character' ? ' · 主角/对照' : d.kind === 'product' ? ' · 当前项目' : ' · 预设'),\n leadLabel: d.title,\n kind: kindMap[d.kind],\n intro: (d.info.find(r => r[0] === '用途') || [, ''])[1] || '资产用于本项目生成',\n tags: d.info.slice(0, 3).map(r => r[1]).filter(Boolean),\n props: baseProps,\n hasTri: !!d.hasTri,\n missingTriHint: '暂未生成三视图(16:9 单图)',\n applyLabel: '应用到当前项目',\n });\n }\n // 用户上传的演员 / 场景(分别累积,source='own')\n const MODEL_OWN = [];\n const SCENE_OWN = [];\n let _curLibKind = 'model';\n let _curLibSource = 'all';\n function _libItemsForSource(kind, src) {\n const isModel = kind === 'model';\n const presets = isModel ? MODEL_LIB : SCENE_LIB;\n const owns = isModel ? MODEL_OWN : SCENE_OWN;\n if (src === 'preset') return presets;\n if (src === 'own') return owns;\n return [...owns, ...presets];\n }\n function _renderLibGrid() {\n const isModel = _curLibKind === 'model';\n const items = _libItemsForSource(_curLibKind, _curLibSource);\n const grid = document.getElementById('ml-grid');\n\n // 「添加演员 / 添加场景」入口卡 · 平台预设是只读素材,不展示入口\n const uploadCardHTML = (_curLibSource === 'preset') ? '' : `\n <div class=\"ml-card ml-upload-card\" id=\"ml-upload-card\" role=\"button\" tabindex=\"0\" aria-label=\"${isModel ? '添加演员' : '添加场景'}\">\n <div class=\"up-thumb\">\n <div class=\"up-plus\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 5v14M5 12h14\"/></svg>\n </div>\n </div>\n <div class=\"ml-card-nm\">${isModel ? '添加演员' : '添加场景'}</div>\n <div class=\"ml-card-sub\">// AI 生成 / 本地上传</div>\n </div>\n `;\n\n grid.innerHTML = uploadCardHTML + items.map(it => `\n <div class=\"ml-card\" data-name=\"${it.name}\" data-sub=\"${it.sub}\">\n <div class=\"placeholder\"><span class=\"ph-frame\">${it.name}</span></div>\n <div class=\"ml-card-nm\">${it.name}</div>\n <div class=\"ml-card-sub\">${it.sub}</div>\n </div>\n `).join('');\n\n const upCard = grid.querySelector('#ml-upload-card');\n if (upCard) {\n upCard.addEventListener('click', () => _openLibUploadChoice());\n upCard.addEventListener('keydown', e => {\n if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _openLibUploadChoice(); }\n });\n }\n\n // 普通卡片 click → 应用 / 详情\n grid.querySelectorAll('.ml-card:not(.ml-upload-card)').forEach(card => {\n card.addEventListener('click', (e) => {\n const name = card.dataset.name;\n const sub = card.dataset.sub;\n if (e.target.closest('[data-apply]')) {\n e.stopPropagation();\n Shell.toast('已应用「' + name + '」', isModel ? '演员库 · 来自' + (_curLibSource === 'own' ? '我的上传' : '预设') : '场景库 · 来自' + (_curLibSource === 'own' ? '我的上传' : '预设'));\n document.getElementById('ml-modal-bg').classList.remove('show');\n return;\n }\n openStripDetail(name, sub, _curLibKind);\n });\n });\n }\n\n /* ════════ 添加演员 / 场景 · 工作台(同模特库 ml-canvas)════════ */\n // 词典:模特→演员、立绘→立绘/场景图、三视图(场景隐藏)\n const _MC_LABELS = {\n actor: {\n title: '添加演员', mono: '// 添加演员 · 工作台',\n emptyTitle: '用 AI 生成一位新演员',\n emptyDesc: '描述外形 + 风格 + 服装,AI 会同时生成立绘 + 正/侧/背三视图,加入演员库。',\n nameSec: '// 演员姓名', namePh: '给演员起个名字…',\n portraitSec: '// 演员立绘', portraitPick: '点击或拖入立绘',\n portraitAiDesc: '在左侧 AI 生成后<br>点击想要的立绘添加到这里',\n promptPh: '描述演员外形、年龄、风格、服饰…例如:清新校园风女生',\n commit: '加入演员库', toast: '已加入演员库',\n examples: [\n { ex: '清新校园风女生,黑色长直发,白色 T 恤 + 牛仔短裙,室内自然光', lbl: '清新校园风女生' },\n { ex: '都市 OL 通勤,黑色西装套装,30 岁知性气质', lbl: '都市 OL 通勤' },\n { ex: '健身房教练男性,运动背心 + 短裤,健身房布景', lbl: '健身教练 · 男' },\n { ex: '日系简约女生,棕色短发,米色针织衫,温柔气质', lbl: '日系简约' },\n ],\n },\n scene: {\n title: '添加场景', mono: '// 添加场景 · 工作台',\n emptyTitle: '用 AI 生成一处新场景',\n emptyDesc: '描述类型 + 氛围 + 光线,AI 会生成场景图,加入场景库。',\n nameSec: '// 场景名称', namePh: '给场景起个名字…',\n portraitSec: '// 场景图', portraitPick: '点击或拖入场景图',\n portraitAiDesc: '在左侧 AI 生成后<br>点击想要的场景图添加到这里',\n promptPh: '描述场景类型、氛围、光线…例如:夜景咖啡馆,暖光灯',\n commit: '加入场景库', toast: '已加入场景库',\n examples: [\n { ex: '日系咖啡馆室内,木质桌面,落地窗自然光,午后氛围', lbl: '日系咖啡馆' },\n { ex: '城市天台夜景,霓虹灯背景,冷色调高对比', lbl: '城市天台夜景' },\n { ex: '极简白色摄影棚,无缝背景,顶光均匀', lbl: '极简摄影棚' },\n { ex: '复古港风街头,湿润地面,招牌灯反射', lbl: '港风街头' },\n ],\n },\n };\n\n function _mcKind() { return _curLibKind === 'model' ? 'actor' : 'scene'; }\n function _mcOwnsArr() { return _curLibKind === 'model' ? MODEL_OWN : SCENE_OWN; }\n function _mcPresetsArr() { return _curLibKind === 'model' ? MODEL_LIB : SCENE_LIB; }\n\n // ─── 工作台 DOM 引用 ───\n const _uploadCanvas = document.getElementById('ml-canvas');\n const _mcUp = document.getElementById('mc-up');\n\n function _applyKindToCanvas() {\n const k = _mcKind();\n const L = _MC_LABELS[k];\n _mcUp.dataset.kind = k;\n document.getElementById('ml-canvas-title').textContent = L.title;\n document.getElementById('ml-canvas-mono').textContent = L.mono;\n document.getElementById('mc-empty-title').textContent = L.emptyTitle;\n document.getElementById('mc-empty-desc').textContent = L.emptyDesc;\n document.getElementById('mc-up-name-label').textContent = L.nameSec;\n document.getElementById('mc-up-name').placeholder = L.namePh;\n document.getElementById('mc-up-portrait-label').textContent = L.portraitSec;\n document.getElementById('mc-portrait-ai-empty-desc').innerHTML = L.portraitAiDesc;\n document.getElementById('mc-portrait-local-t').textContent = L.portraitPick;\n // 场景仅允许 1 张本地图;演员允许多张\n const _localInput = document.getElementById('mc-portrait-local-input');\n const _localD = document.querySelector('.mc-portrait-local .drop .d');\n if (k === 'scene') {\n _localInput.removeAttribute('multiple');\n if (_localD) _localD.textContent = '仅支持 1 张 JPG / PNG / WEBP · ≤ 10MB';\n } else {\n _localInput.setAttribute('multiple', '');\n if (_localD) _localD.textContent = '支持多张 JPG / PNG / WEBP · ≤ 10MB / 张';\n }\n document.getElementById('mc-input-text').placeholder = L.promptPh;\n document.getElementById('mc-up-commit-label').textContent = L.commit;\n // 示例 chip 重渲(场景需要不同的示例)\n const ex = document.getElementById('mc-empty-examples');\n if (ex && !ex.dataset.cleanInit) {\n ex.dataset.cleanInit = '1';\n }\n ex.innerHTML = L.examples.map(e => `<button class=\"ex\" type=\"button\" data-ex=\"${e.ex.replace(/\"/g, '"')}\">${e.lbl}</button>`).join('');\n // 重新绑定示例 click\n ex.querySelectorAll('.ex').forEach(b => {\n b.addEventListener('click', () => {\n const t = document.getElementById('mc-input-text');\n t.value = b.dataset.ex;\n t.dispatchEvent(new Event('input'));\n t.focus();\n });\n });\n }\n\n function _openLibUploadChoice() {\n _applyKindToCanvas();\n // 当前 kind 下重渲右侧栏:刷新底部状态文案(场景:名称 / 场景图,无三视图)\n if (typeof _renderRight === 'function') { try { _renderRight(); } catch(e) {} }\n _uploadCanvas.classList.add('show');\n _uploadCanvas.setAttribute('aria-hidden', 'false');\n }\n function _closeUploadCanvasNow() {\n _uploadCanvas.classList.remove('show');\n _uploadCanvas.setAttribute('aria-hidden', 'true');\n }\n\n // ─── 工作台状态(AI / Local 两个 tab 各自独立)───\n let _mcRightTab = 'ai';\n const _mcAi = { name: '', portrait: null, triVersions: [], triActiveIdx: -1 };\n const _mcLocal = { name: '', portraits: [], triVersions: [], triActiveIdx: -1 };\n function _mcCurState() { return _mcRightTab === 'ai' ? _mcAi : _mcLocal; }\n function _hasTri(s) { return s.triVersions.length > 0 && s.triActiveIdx >= 0; }\n function _kindRequiresTri() { return _mcKind() === 'actor'; }\n\n function _isWorkbenchDirty() {\n if (!_uploadCanvas.classList.contains('show')) return false;\n if ((_mcAi.name || '').trim()) return true;\n if (_mcAi.portrait) return true;\n if (_mcAi.triVersions.length > 0) return true;\n if ((_mcLocal.name || '').trim()) return true;\n if (_mcLocal.portraits.length > 0) return true;\n if (_mcLocal.triVersions.length > 0) return true;\n return false;\n }\n function _resetWorkbenchState() {\n if (_mcAi.portrait?.cellEl) _mcAi.portrait.cellEl.classList.remove('selected');\n _mcAi.name = ''; _mcAi.portrait = null; _mcAi.triVersions = []; _mcAi.triActiveIdx = -1;\n _mcLocal.name = ''; _mcLocal.portraits = []; _mcLocal.triVersions = []; _mcLocal.triActiveIdx = -1;\n const nameInput = document.getElementById('mc-up-name');\n if (nameInput) nameInput.value = '';\n _renderRight();\n }\n\n // ─── 二次确认弹窗 ───\n const _leaveBg = document.getElementById('mc-leave-bg');\n const _leaveBody = document.getElementById('mc-leave-body');\n let _leavePending = null;\n function _openLeaveConfirm(mode, onConfirm) {\n const ki = _mcKind() === 'actor' ? '加入演员库' : '加入场景库';\n if (mode === 'nav') {\n _leaveBody.innerHTML = `工作台已有内容,跳转到其他页面后<b>不会保存</b>。可继续编辑并点「${ki}」来保留进度。`;\n } else {\n _leaveBody.innerHTML = `工作台已有内容,退出后<b>不会保存</b>。可继续编辑并点「${ki}」来保留进度。`;\n }\n _leavePending = onConfirm || null;\n _leaveBg.classList.add('show');\n _leaveBg.setAttribute('aria-hidden', 'false');\n }\n function _closeLeaveConfirm() {\n _leaveBg.classList.remove('show');\n _leaveBg.setAttribute('aria-hidden', 'true');\n _leavePending = null;\n }\n document.getElementById('mc-leave-cancel').addEventListener('click', _closeLeaveConfirm);\n _leaveBg.addEventListener('click', e => { if (e.target === _leaveBg) _closeLeaveConfirm(); });\n document.getElementById('mc-leave-confirm').addEventListener('click', () => {\n const fn = _leavePending;\n _closeLeaveConfirm();\n if (typeof fn === 'function') fn();\n });\n\n function _closeUploadChoice() {\n if (_isWorkbenchDirty()) {\n _openLeaveConfirm('exit', () => { _resetWorkbenchState(); _closeUploadCanvasNow(); });\n return;\n }\n _closeUploadCanvasNow();\n }\n document.getElementById('ml-canvas-back').addEventListener('click', _closeUploadChoice);\n document.addEventListener('keydown', e => {\n if (e.key === 'Escape' && _uploadCanvas.classList.contains('show') && !_leaveBg.classList.contains('show')) {\n _closeUploadChoice();\n }\n });\n\n // 全局拦截 · 工作台展开时,根据脏态决定是否二次确认\n document.addEventListener('click', e => {\n if (!_uploadCanvas.classList.contains('show')) return;\n const dirty = _isWorkbenchDirty();\n\n // 库 X · 关闭整个库 modal(脏态确认 · 非脏直接关)\n if (e.target.closest('#ml-close-btn')) {\n e.preventDefault(); e.stopPropagation();\n const doClose = () => {\n _resetWorkbenchState();\n _closeUploadCanvasNow();\n document.getElementById('ml-modal-bg').classList.remove('show');\n };\n if (dirty) _openLeaveConfirm('exit', doClose); else doClose();\n return;\n }\n // 库左侧「来源」筛选项 · 切换 = 离开工作台\n const sideItem = e.target.closest('#ml-side .ml-side-item');\n if (sideItem) {\n e.preventDefault(); e.stopPropagation();\n const src = sideItem.dataset.source;\n const doSwitch = () => {\n _resetWorkbenchState();\n _closeUploadCanvasNow();\n _curLibSource = src;\n document.querySelectorAll('#ml-side .ml-side-item').forEach(x =>\n x.classList.toggle('active', x.dataset.source === src));\n _renderLibGrid();\n };\n if (dirty) _openLeaveConfirm('nav', doSwitch); else doSwitch();\n return;\n }\n\n if (!dirty) return; // 后续仅脏态时拦截\n\n // 外页跳转\n const a = e.target.closest('a[href]');\n if (a) {\n const href = a.getAttribute('href');\n if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;\n e.preventDefault(); e.stopPropagation();\n _openLeaveConfirm('nav', () => { _resetWorkbenchState(); _closeUploadCanvasNow(); location.href = href; });\n return;\n }\n // 余额胶囊\n if (e.target.closest('.balance-chip')) {\n e.preventDefault(); e.stopPropagation();\n _openLeaveConfirm('nav', () => { _resetWorkbenchState(); _closeUploadCanvasNow(); location.href = 'account.html'; });\n return;\n }\n }, true);\n\n window.addEventListener('beforeunload', e => {\n if (_isWorkbenchDirty()) { e.preventDefault(); e.returnValue = ''; return ''; }\n });\n\n // ─── 左:AI 生成区 ───\n const _mcInputText = document.getElementById('mc-input-text');\n const _mcSendBtn = document.getElementById('mc-send-btn');\n _mcInputText?.addEventListener('input', () => {\n _mcSendBtn.disabled = _mcInputText.value.trim().length === 0;\n _mcInputText.style.height = 'auto';\n _mcInputText.style.height = Math.min(_mcInputText.scrollHeight, 220) + 'px';\n });\n const _MC_SVG = {\n rerun: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5\"/></svg>',\n dl: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3\"/></svg>',\n more: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"5\" cy=\"12\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"12\" cy=\"12\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"19\" cy=\"12\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/></svg>',\n adopt: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z\"/></svg>',\n del: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg>',\n edit: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 20h9\"/><path d=\"M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4L16.5 3.5z\"/></svg>',\n };\n\n function _mcAppendMsg(prompt, refs) {\n const inner = document.getElementById('mc-stream-inner');\n if (!inner) return;\n const empty = inner.querySelector('.mc-empty');\n if (empty) empty.remove();\n const safe = String(prompt).replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'})[c]);\n const tags = ['3:4', '默认', '4 张'];\n const tagsHtml = tags.map(t => `<span class=\"meta-chip\">${t}</span>`).join('<span class=\"sep\">·</span>');\n const subjLabel = _mcKind() === 'actor' ? '演员' : '场景';\n const cells = Array.from({ length: 4 }, (_, i) => `\n <div class=\"mc-cell gen\" data-idx=\"${i}\">\n <div class=\"ph-frame\">生成中 · v${i + 1}</div>\n <div class=\"cell-ops\" hidden>\n <button type=\"button\" data-act=\"cell-rerun\" title=\"再次生成\">${_MC_SVG.rerun}</button>\n <button type=\"button\" data-act=\"cell-dl\" title=\"下载\">${_MC_SVG.dl}</button>\n </div>\n </div>`).join('');\n const msg = document.createElement('div');\n msg.className = 'mc-msg';\n msg.innerHTML = `\n <div class=\"mc-msg-prompt\">\n <div class=\"quote\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 21c0-3.5 3-6 6-6s6 2.5 6 6\"/><circle cx=\"9\" cy=\"8\" r=\"4\"/><path d=\"M19 8v6M22 11h-6\"/></svg>\n </div>\n <div class=\"pt\"><div class=\"pt-text\">${safe}</div><div class=\"pt-tags\">${tagsHtml}</div></div>\n </div>\n <div class=\"mc-msg-grid\">${cells}</div>\n <div class=\"mc-msg-ops\">\n <button type=\"button\" data-act=\"edit\">${_MC_SVG.edit}重新编辑</button>\n <button type=\"button\" data-act=\"rerun\">${_MC_SVG.rerun}再次生成</button>\n </div>`;\n inner.appendChild(msg);\n setTimeout(() => {\n msg.querySelectorAll('.mc-cell').forEach((c, i) => {\n c.classList.remove('gen');\n const ph = c.querySelector('.ph-frame');\n if (ph) ph.textContent = subjLabel + ' · v' + (i + 1);\n const ops = c.querySelector('.cell-ops');\n if (ops) ops.hidden = false;\n });\n _bindMcCellPick();\n }, 1600);\n msg.querySelectorAll('[data-act=\"rerun\"]').forEach(b =>\n b.addEventListener('click', e => { e.stopPropagation(); _mcAppendMsg(prompt, []); }));\n msg.querySelectorAll('[data-act=\"edit\"]').forEach(b =>\n b.addEventListener('click', e => {\n e.stopPropagation();\n _mcInputText.value = prompt;\n _mcInputText.focus();\n _mcInputText.dispatchEvent(new Event('input'));\n }));\n msg.querySelectorAll('[data-act=\"cell-rerun\"]').forEach(b =>\n b.addEventListener('click', e => {\n e.stopPropagation();\n const cell = b.closest('.mc-cell');\n const ops = cell.querySelector('.cell-ops');\n const ph = cell.querySelector('.ph-frame');\n const idx = Number(cell.dataset.idx || 0);\n cell.classList.add('gen'); cell.classList.remove('selected');\n if (ops) ops.hidden = true;\n if (ph) ph.textContent = '生成中 · v' + (idx + 1);\n setTimeout(() => {\n cell.classList.remove('gen');\n if (ph) ph.textContent = subjLabel + ' · v' + (idx + 1);\n if (ops) ops.hidden = false;\n }, 1200 + Math.random() * 600);\n Shell.toast('已重跑', '该图重新生成中');\n }));\n msg.querySelectorAll('[data-act=\"cell-dl\"]').forEach(b =>\n b.addEventListener('click', e => { e.stopPropagation(); Shell.toast('下载', '已开始下载 · MOCK'); }));\n const stream = document.getElementById('mc-stream');\n if (stream) stream.scrollTo({ top: stream.scrollHeight, behavior: 'smooth' });\n }\n\n _mcSendBtn?.addEventListener('click', () => {\n const txt = _mcInputText.value.trim();\n if (!txt) return;\n _mcAppendMsg(txt, _mcRefList.slice());\n _mcInputText.value = '';\n _mcInputText.style.height = 'auto';\n _mcSendBtn.disabled = true;\n _mcRefList = [];\n _renderMcRefs();\n });\n _mcInputText?.addEventListener('keydown', e => {\n if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); _mcSendBtn.click(); }\n });\n\n // 参考图上传\n const _mcAiRefInput = document.getElementById('mc-ai-ref-input');\n const _mcRefs = document.getElementById('mc-input-refs');\n let _mcRefList = [];\n document.getElementById('mc-add-btn')?.addEventListener('click', () => _mcAiRefInput.click());\n _mcAiRefInput?.addEventListener('change', e => {\n const files = [...(e.target.files || [])].filter(f => /^image\\//.test(f.type));\n files.forEach(f => _mcRefList.push({ name: f.name, url: URL.createObjectURL(f) }));\n e.target.value = '';\n _renderMcRefs();\n });\n function _renderMcRefs() {\n if (!_mcRefs) return;\n _mcRefs.innerHTML = _mcRefList.map((r, i) => `\n <div class=\"mc-input-ref\">\n <img src=\"${r.url}\" alt=\"${r.name}\">\n <button class=\"x\" data-idx=\"${i}\" aria-label=\"移除\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M18 6L6 18M6 6l12 12\"/></svg></button>\n </div>`).join('');\n _mcRefs.querySelectorAll('.x').forEach(x => x.addEventListener('click', () => {\n _mcRefList.splice(+x.dataset.idx, 1);\n _renderMcRefs();\n }));\n }\n\n // Tab 切换\n document.querySelectorAll('.mc-up-tab').forEach(btn => {\n btn.addEventListener('click', () => {\n _mcRightTab = btn.dataset.tab;\n document.querySelectorAll('.mc-up-tab').forEach(b => b.classList.toggle('active', b === btn));\n document.querySelectorAll('.mc-up-body [data-show]').forEach(el => {\n el.hidden = el.dataset.show !== _mcRightTab;\n });\n document.getElementById('mc-up-name').value = _mcCurState().name;\n _renderRight();\n });\n });\n\n const _mcUpName = document.getElementById('mc-up-name');\n _mcUpName?.addEventListener('input', () => {\n _mcCurState().name = _mcUpName.value;\n _updateCommit();\n });\n\n // AI tab · 立绘选中态\n const _aiEmpty = document.getElementById('mc-portrait-ai-empty');\n const _aiPicked = document.getElementById('mc-portrait-ai-picked');\n const _aiLabel = document.getElementById('mc-portrait-ai-label');\n function _renderAiPortrait() {\n if (_mcAi.portrait) {\n _aiEmpty.hidden = true; _aiPicked.hidden = false;\n _aiLabel.textContent = _mcAi.portrait.label || '立绘';\n } else { _aiEmpty.hidden = false; _aiPicked.hidden = true; }\n }\n function _setAiPortrait(data) {\n _mcAi.portrait = data;\n _renderAiPortrait();\n _updateTriBtn(); _updateCommit();\n }\n function _clearAiPortrait() {\n if (_mcAi.portrait?.cellEl) _mcAi.portrait.cellEl.classList.remove('selected');\n _mcAi.portrait = null;\n _mcAi.triVersions = []; _mcAi.triActiveIdx = -1;\n _renderAiPortrait(); _renderTriView(); _updateTriBtn(); _updateCommit();\n }\n document.getElementById('mc-portrait-ai-clear').addEventListener('click', _clearAiPortrait);\n\n // Local tab · 多张立绘 / 场景图\n const _lpDrop = document.getElementById('mc-portrait-local-drop');\n const _lpInput = document.getElementById('mc-portrait-local-input');\n const _lpList = document.getElementById('mc-portrait-local-list');\n const _lpCount = document.getElementById('mc-portrait-local-count');\n function _renderLocalPortraits() {\n _lpCount.textContent = _mcLocal.portraits.length;\n _lpList.innerHTML = _mcLocal.portraits.map((p, i) => `\n <div class=\"thumb\">\n <img src=\"${p.url}\" alt=\"${p.name}\">\n <button class=\"x\" data-idx=\"${i}\" aria-label=\"移除\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M18 6L6 18M6 6l12 12\"/></svg></button>\n </div>`).join('');\n _lpList.querySelectorAll('.x').forEach(x => x.addEventListener('click', () => {\n _mcLocal.portraits.splice(+x.dataset.idx, 1);\n if (_mcLocal.portraits.length === 0) {\n _mcLocal.triVersions = []; _mcLocal.triActiveIdx = -1;\n }\n _renderLocalPortraits(); _renderTriView(); _updateTriBtn(); _updateCommit();\n }));\n }\n function _lpAdd(files) {\n const imgs = [...(files || [])].filter(f => /^image\\//.test(f.type));\n if (!imgs.length) return;\n // 场景:仅允许 1 张 · 新选直接替换旧的(后选覆盖前选)\n if (_mcKind() === 'scene') {\n const f = imgs[0];\n _mcLocal.portraits = [{ file: f, url: URL.createObjectURL(f), name: f.name, size: f.size }];\n } else {\n imgs.forEach(f => _mcLocal.portraits.push({ file: f, url: URL.createObjectURL(f), name: f.name, size: f.size }));\n }\n _renderLocalPortraits(); _updateTriBtn(); _updateCommit();\n }\n _lpDrop?.addEventListener('click', () => _lpInput.click());\n _lpDrop?.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _lpInput.click(); } });\n _lpInput?.addEventListener('change', e => { _lpAdd(e.target.files); e.target.value = ''; });\n ['dragenter', 'dragover'].forEach(ev => _lpDrop?.addEventListener(ev, e => { e.preventDefault(); _lpDrop.classList.add('dragover'); }));\n ['dragleave', 'drop'].forEach(ev => _lpDrop?.addEventListener(ev, e => { e.preventDefault(); _lpDrop.classList.remove('dragover'); }));\n _lpDrop?.addEventListener('drop', e => _lpAdd(e.dataTransfer?.files));\n\n // 三视图模块 · 演员使用 · 场景隐藏\n const _triSec = document.getElementById('mc-triview-sec');\n const _triResultImg = document.getElementById('mc-triview-result');\n const _triFrame = document.getElementById('mc-triview-frame');\n const _triGenBtn = document.getElementById('mc-triview-gen-btn');\n const _triHint = document.getElementById('mc-triview-hint');\n const _triOps = document.getElementById('mc-triview-ops');\n const _triRerunBtn = document.getElementById('mc-triview-rerun');\n const _triHistory = document.getElementById('mc-triview-history');\n const _triHistoryRow = document.getElementById('mc-triview-history-row');\n const _triHistoryCount = document.getElementById('mc-triview-history-count');\n let _triGenerating = false;\n\n function _portraitReady() {\n return _mcRightTab === 'ai' ? !!_mcAi.portrait : _mcLocal.portraits.length > 0;\n }\n function _renderTriView() {\n if (!_kindRequiresTri()) return; // 场景模式整个模块被 CSS 隐藏\n const s = _mcCurState();\n const has = _hasTri(s);\n _triSec.classList.toggle('has-result', has);\n if (_triGenerating) {\n _triGenBtn.hidden = true; _triHint.hidden = true;\n _triOps.hidden = !has; _triHistory.hidden = !has;\n _triFrame.textContent = '三视图生成中…';\n _triResultImg.classList.add('gen');\n _triRerunBtn.disabled = true;\n if (has) _renderTriHistory(s);\n } else if (has) {\n _triGenBtn.hidden = true; _triHint.hidden = true;\n _triOps.hidden = false; _triHistory.hidden = false;\n const ver = s.triVersions[s.triActiveIdx];\n _triFrame.textContent = `三视图(正/侧/背) · ${ver.label}`;\n _triResultImg.classList.remove('gen');\n _triRerunBtn.disabled = false;\n _renderTriHistory(s);\n } else {\n _triGenBtn.hidden = false; _triHint.hidden = false;\n _triOps.hidden = true; _triHistory.hidden = true;\n _triFrame.textContent = '三视图(正/侧/背)';\n _triResultImg.classList.remove('gen');\n }\n }\n function _renderTriHistory(s) {\n _triHistoryCount.textContent = s.triVersions.length;\n _triHistoryRow.innerHTML = s.triVersions.map((ver, i) => `\n <div class=\"h-thumb${i === s.triActiveIdx ? ' active' : ''}\" data-idx=\"${i}\" title=\"${ver.label} · ${ver.ts}${i === s.triActiveIdx ? ' · 当前采用' : ''}\">\n <span class=\"badge\">当前</span><span class=\"v\">${ver.label}</span>\n </div>`).join('');\n _triHistoryRow.querySelectorAll('.h-thumb').forEach(el => {\n el.addEventListener('click', () => {\n const idx = Number(el.dataset.idx);\n if (idx === s.triActiveIdx) return;\n s.triActiveIdx = idx;\n _renderTriView(); _updateCommit();\n });\n });\n }\n function _updateTriBtn() {\n if (!_kindRequiresTri()) return;\n if (_hasTri(_mcCurState())) return;\n const ok = _portraitReady() && !_triGenerating;\n _triGenBtn.disabled = !ok;\n _triHint.textContent = _portraitReady()\n ? '// 一键生成正/侧/背 三视图'\n : (_mcRightTab === 'ai' ? '// 先选中左侧 AI 立绘' : '// 先上传至少 1 张立绘');\n }\n function _startTriGen() {\n if (!_kindRequiresTri()) return;\n if (!_portraitReady() || _triGenerating) return;\n _triGenerating = true;\n const stateAtStart = _mcCurState();\n _renderTriView();\n setTimeout(() => {\n _triGenerating = false;\n const now = new Date();\n const ts = String(now.getHours()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0');\n const label = 'v' + (stateAtStart.triVersions.length + 1);\n stateAtStart.triVersions.push({ ts, label });\n stateAtStart.triActiveIdx = stateAtStart.triVersions.length - 1;\n if (stateAtStart === _mcCurState()) _renderTriView();\n _updateTriBtn(); _updateCommit();\n }, 1600);\n }\n _triGenBtn.addEventListener('click', _startTriGen);\n _triRerunBtn.addEventListener('click', _startTriGen);\n\n // 整体渲染 + 提交\n const _mcUpCommit = document.getElementById('mc-up-commit');\n const _mcUpStat = document.getElementById('mc-up-stat');\n function _updateCommit() {\n const s = _mcCurState();\n const nameOk = s.name.trim().length > 0;\n const portraitOk = _portraitReady();\n const needTri = _kindRequiresTri();\n const triOk = needTri ? _hasTri(s) : true;\n const ready = nameOk && portraitOk && triOk;\n _mcUpCommit.disabled = !ready;\n if (ready) {\n _mcUpStat.classList.add('ok');\n _mcUpStat.innerHTML = `✓ 已就绪 · <b>${s.name}</b>`;\n } else {\n _mcUpStat.classList.remove('ok');\n const miss = [];\n if (!nameOk) miss.push(_mcKind() === 'actor' ? '姓名' : '名称');\n if (!portraitOk) miss.push(_mcKind() === 'actor' ? '立绘' : '场景图');\n if (needTri && !triOk) miss.push('三视图');\n _mcUpStat.innerHTML = `// 待完成 · <b>${miss.join(' / ')}</b>`;\n }\n }\n function _renderRight() {\n _renderAiPortrait();\n _renderLocalPortraits();\n _renderTriView();\n _updateTriBtn();\n _updateCommit();\n }\n _renderRight();\n\n _mcUpCommit?.addEventListener('click', () => {\n const s = _mcCurState();\n if (_mcUpCommit.disabled) return;\n const baseName = s.name.trim().slice(0, 12);\n const ts = Date.now().toString(36);\n const subjLabel = _mcKind() === 'actor' ? '演员' : '场景';\n const ownsArr = _mcOwnsArr();\n // 持久化多张立绘 / 场景图 + 三视图历史版本\n const portraits = _mcRightTab === 'ai'\n ? (_mcAi.portrait ? [{ url: '', name: _mcAi.portrait.label || baseName, label: _mcAi.portrait.label || subjLabel }] : [])\n : _mcLocal.portraits.map((p, i) => ({ url: p.url, name: p.name || `${baseName}-${i+1}`, label: `本地 ${i+1}` }));\n const triVersions = _kindRequiresTri() ? s.triVersions.map(v => ({ ts: v.ts, label: v.label })) : [];\n ownsArr.unshift({\n id: 'up-' + ts,\n name: baseName,\n sub: (_mcRightTab === 'ai' ? 'AI 生成' : '我的上传') + ' · ' + (_kindRequiresTri() ? '已含三视图' : subjLabel),\n source: 'own',\n portraits, triVersions,\n kind: _mcKind(),\n });\n const L = _MC_LABELS[_mcKind()];\n Shell.toast(L.toast, `${baseName} · 来源 ${_mcRightTab === 'ai' ? 'AI 生成' : '我的上传'}`);\n _resetWorkbenchState();\n _curLibSource = 'own';\n const side = document.getElementById('ml-side');\n side.querySelectorAll('.ml-side-item').forEach(x =>\n x.classList.toggle('active', x.dataset.source === 'own'));\n const ownCt = side.querySelector('.ml-side-item[data-source=\"own\"] .ct');\n if (ownCt) ownCt.textContent = ownsArr.length;\n const allCt = side.querySelector('.ml-side-item[data-source=\"all\"] .ct');\n if (allCt) allCt.textContent = ownsArr.length + _mcPresetsArr().length;\n _renderLibGrid();\n _closeUploadCanvasNow();\n });\n\n // 左侧 cell 可点击选为立绘(仅 AI tab)\n function _bindMcCellPick() {\n document.querySelectorAll('#mc-stream .mc-cell').forEach(cell => {\n if (cell.dataset.boundPick) return;\n cell.dataset.boundPick = '1';\n if (!cell.querySelector('.pick-badge')) {\n const b = document.createElement('span');\n b.className = 'pick-badge';\n b.textContent = '已选用';\n cell.appendChild(b);\n }\n cell.addEventListener('click', () => {\n if (_mcRightTab !== 'ai') {\n Shell.toast('请切到「AI 生成」标签', '只有 AI 模式才能选用左侧立绘');\n return;\n }\n if (cell.classList.contains('gen')) return;\n if (cell.classList.contains('selected')) { _clearAiPortrait(); return; }\n document.querySelectorAll('#mc-stream .mc-cell.selected').forEach(c => c.classList.remove('selected'));\n cell.classList.add('selected');\n const label = cell.querySelector('.ph-frame')?.textContent || '立绘';\n _mcAi.triVersions = []; _mcAi.triActiveIdx = -1;\n _renderTriView();\n _setAiPortrait({ label, cellEl: cell });\n });\n });\n }\n\n function openLib(kind) {\n _curLibKind = kind;\n _curLibSource = 'all';\n const isModel = kind === 'model';\n const title = isModel ? '演员库' : '场景库';\n const presets = isModel ? MODEL_LIB : SCENE_LIB;\n const owns = isModel ? MODEL_OWN : SCENE_OWN;\n\n document.getElementById('ml-modal-title').textContent = title;\n document.getElementById('ml-modal-ct').textContent = '// 共 ' + (presets.length + owns.length) + ' 个';\n\n // 侧栏 · 来源\n const side = document.getElementById('ml-side');\n side.innerHTML = `\n <div class=\"ml-side-h\">来源</div>\n <div class=\"ml-side-item active\" data-source=\"all\">全部 <span class=\"ct\">${presets.length + owns.length}</span></div>\n <div class=\"ml-side-item\" data-source=\"preset\">平台预设 <span class=\"ct\">${presets.length}</span></div>\n <div class=\"ml-side-item\" data-source=\"own\">我的上传 <span class=\"ct\">${owns.length}</span></div>\n `;\n side.querySelectorAll('.ml-side-item').forEach(it => {\n it.addEventListener('click', () => {\n side.querySelectorAll('.ml-side-item').forEach(x => x.classList.remove('active'));\n it.classList.add('active');\n _curLibSource = it.dataset.source;\n _renderLibGrid();\n });\n });\n\n // toolbar · chip groups (去掉了 btn-up 上传按钮,改用网格内入口卡)\n const toolbar = document.getElementById('ml-toolbar');\n if (isModel) {\n toolbar.innerHTML = `\n <div class=\"chip-group\">\n <span class=\"lbl\">性别</span>\n <button class=\"chip active\" type=\"button\">全部</button>\n <button class=\"chip\" type=\"button\">女</button>\n <button class=\"chip\" type=\"button\">男</button>\n </div>\n <div class=\"chip-group\">\n <span class=\"lbl\">年龄</span>\n <button class=\"chip active\" type=\"button\">全部</button>\n <button class=\"chip\" type=\"button\">青年</button>\n <button class=\"chip\" type=\"button\">中年</button>\n </div>\n `;\n } else {\n toolbar.innerHTML = `\n <div class=\"chip-group\">\n <span class=\"lbl\">类型</span>\n <button class=\"chip active\" type=\"button\">全部</button>\n <button class=\"chip\" type=\"button\">室内</button>\n <button class=\"chip\" type=\"button\">室外</button>\n </div>\n <div class=\"chip-group\">\n <span class=\"lbl\">氛围</span>\n <button class=\"chip active\" type=\"button\">全部</button>\n <button class=\"chip\" type=\"button\">日</button>\n <button class=\"chip\" type=\"button\">夜</button>\n </div>\n `;\n }\n toolbar.querySelectorAll('.chip-group').forEach(group => {\n group.querySelectorAll('.chip').forEach(c => {\n c.addEventListener('click', () => {\n group.querySelectorAll('.chip').forEach(x => x.classList.remove('active'));\n c.classList.add('active');\n });\n });\n });\n\n // 卡片网格(含 + 入口 + apply 绑定都在 _renderLibGrid 内完成)\n _renderLibGrid();\n\n document.getElementById('ml-modal-bg').classList.add('show');\n }\n function closeLib() {\n document.getElementById('ml-modal-bg').classList.remove('show');\n }\n\n function init() {\n // 侧栏 ttab → 锚点\n document.querySelectorAll('.asset-side .ttab[data-jump]').forEach(tab => {\n tab.addEventListener('click', () => {\n document.querySelectorAll('.asset-side .ttab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n const target = document.getElementById(tab.dataset.jump);\n if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });\n });\n });\n // 卡片 click → 详情(点击空白区域;按钮和可编辑提示词带 data-stop 不触发)\n document.querySelectorAll('.asset-card-2[data-asset-id]').forEach(card => {\n card.addEventListener('click', (e) => {\n if (e.target.closest('[data-stop]')) return;\n openDetail(card.dataset.assetId);\n });\n });\n // 重跑按钮\n document.querySelectorAll('[data-rerun]').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n const card = btn.closest('.asset-card-2');\n const name = card ? card.querySelector('strong').textContent : '资产';\n Shell.toast('重跑「' + name + '」', 'POST /assets/regen · 使用当前提示词');\n });\n });\n // 替换按钮\n document.querySelectorAll('[data-replace]').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n const card = btn.closest('.asset-card-2');\n const name = card ? card.querySelector('strong').textContent : '资产';\n const kind = card ? card.dataset.assetKind : '';\n if (kind === 'character') openLib('model');\n else if (kind === 'scene') openLib('scene');\n else Shell.toast('替换「' + name + '」', '请从素材库挑选或上传');\n });\n });\n // 提示词区域 blur → 保存\n document.querySelectorAll('.asset-card-2 .prompt-box[contenteditable=\"true\"]').forEach(box => {\n box.addEventListener('blur', () => {\n const card = box.closest('.asset-card-2');\n const name = card ? card.querySelector('strong').textContent : '资产';\n Shell.toast('提示词已更新', name + ' · 下次重跑生效');\n });\n });\n // 演员库 / 场景库\n document.getElementById('open-model-lib')?.addEventListener('click', () => openLib('model'));\n document.getElementById('open-scene-lib')?.addEventListener('click', () => openLib('scene'));\n // 新增\n document.getElementById('asset-add-character')?.addEventListener('click', () => {\n document.getElementById('new-character-modal').classList.add('show');\n });\n document.getElementById('asset-add-scene')?.addEventListener('click', () => {\n Shell.toast('+ 新增场景', '请上传场景图或填写提示词');\n });\n // modal 通用关闭\n document.querySelectorAll('.asset-modal-bg').forEach(bg => {\n bg.addEventListener('click', (e) => { if (e.target === bg) bg.classList.remove('show'); });\n bg.querySelectorAll('.x, [data-modal-close]').forEach(el => {\n el.addEventListener('click', () => bg.classList.remove('show'));\n });\n });\n // 详情 modal · 应用 → 关详情 + 关演员/场景库 → 回到项目页\n document.getElementById('asset-detail-apply-btn')?.addEventListener('click', () => {\n const name = document.getElementById('asset-detail-title').textContent;\n document.getElementById('asset-detail-modal').classList.remove('show');\n document.getElementById('ml-modal-bg')?.classList.remove('show');\n Shell.toast('已应用「' + name + '」', '已加入当前项目');\n });\n // 详情 modal · AI 生成三视图\n document.querySelectorAll('.ai-gen-btn').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n Shell.toast('AI 生成三视图中', '约 12s · POST /assets/tri-view');\n });\n });\n // 新增人物 · 保存\n document.getElementById('nc-save-btn')?.addEventListener('click', () => {\n Shell.toast('已新增人物', '保存到资产库 · 待 AI 补三视图');\n document.getElementById('new-character-modal').classList.remove('show');\n });\n // 把所有 modal 提升到 body 直接子级,避免被 .content 滚动容器裁切\n document.querySelectorAll('.asset-modal-bg, .ml-modal-bg').forEach(el => {\n if (el.parentElement !== document.body) document.body.appendChild(el);\n });\n // 演员库 / 场景库 全屏弹窗关闭按钮(仍保留,经\"替换\"气泡进入)\n document.getElementById('ml-close-btn')?.addEventListener('click', closeLib);\n // 注入当前项目的商品名(从 URL ?product= 或默认)\n const nameEls = ['asset-prod-name', 'asset-prod-card-name'];\n nameEls.forEach(eid => {\n const el = document.getElementById(eid);\n if (el) el.textContent = CURRENT_PRODUCT_NAME;\n });\n const thumbLbl = document.getElementById('asset-prod-thumb-label');\n if (thumbLbl) thumbLbl.textContent = CURRENT_PRODUCT_NAME + ' · 主图';\n // 商品卡 · AI 生成三视图 → 右侧 prod-preview · 预览/采用 双状态 + 点击主图放大\n (function setupProdPreview() {\n const aigenBtn = document.getElementById('asset-prod-aigen-btn');\n const pane = document.getElementById('asset-prod-preview');\n const img = document.getElementById('prod-preview-img');\n const statusEl = document.getElementById('prod-preview-status');\n const foot = document.getElementById('prod-preview-foot');\n const triBadge = document.getElementById('asset-prod-tri-badge');\n const prodAction = document.getElementById('asset-prod-action');\n const history = document.getElementById('prod-preview-history');\n const historyRow = document.getElementById('prod-preview-history-row');\n const historyCount = document.getElementById('prod-preview-history-count');\n if (!aigenBtn || !pane || !img || !statusEl || !foot || !history || !historyRow) return;\n\n const versions = []; // [{ ts, label }]\n let previewIdx = -1; // 主图正在「预览」哪一版(浏览态,不动采用状态)\n let adoptedIdx = -1; // 真正被「采用」的那一版,决定商品资产生效版本\n let generating = false;\n const savedTri = ProjectStore.data.stage2?.productTri || null;\n if (savedTri) {\n if (Array.isArray(savedTri.versions)) versions.push(...savedTri.versions);\n if (Number.isInteger(savedTri.previewIdx)) previewIdx = savedTri.previewIdx;\n if (Number.isInteger(savedTri.adoptedIdx)) adoptedIdx = savedTri.adoptedIdx;\n }\n\n function saveTriState() {\n ProjectStore.saveStage('stage2', {\n ...(ProjectStore.data.stage2 || {}),\n productTri: { versions, previewIdx, adoptedIdx },\n });\n }\n\n function prodName() {\n return CURRENT_PRODUCT_NAME || (document.getElementById('asset-prod-card-name')?.textContent ?? '商品');\n }\n\n function renderHistory() {\n if (versions.length === 0) {\n history.classList.remove('show');\n return;\n }\n history.classList.add('show');\n historyCount.textContent = versions.length;\n historyRow.innerHTML = versions.map((ver, i) => {\n const isAdopted = i === adoptedIdx;\n const isPreview = i === previewIdx;\n const cls = [\n isAdopted ? 'adopted' : '',\n isPreview && !isAdopted ? 'previewing' : '',\n ].filter(Boolean).join(' ');\n const titleParts = [ver.label, ver.ts];\n if (isAdopted) titleParts.push('已采用');\n else if (isPreview) titleParts.push('预览中');\n return `\n <div class=\"h-thumb ${cls}\" data-idx=\"${i}\" title=\"${titleParts.join(' · ')}\">\n <span class=\"badge\">已采用</span>\n <span class=\"v\">${ver.label}</span>\n </div>\n `;\n }).join('');\n historyRow.querySelectorAll('.h-thumb').forEach(el => {\n el.addEventListener('click', () => {\n const idx = Number(el.dataset.idx);\n if (idx === previewIdx) return;\n setPreview(idx);\n });\n });\n }\n\n function renderMain() {\n if (previewIdx < 0) return;\n const ver = versions[previewIdx];\n const isAdopted = previewIdx === adoptedIdx;\n img.innerHTML = `<span class=\"ph-frame\">${prodName()} · 三视图(正/侧/背) · ${ver.label}</span>`;\n img.classList.add('is-zoomable');\n img.title = '点击放大查看';\n statusEl.textContent = isAdopted\n ? `${ver.label} · 已采用,不满意可重跑`\n : `${ver.label} · 预览中(未采用)`;\n foot.innerHTML = `\n <button class=\"btn btn-ghost btn-sm\" id=\"prod-preview-rerun\">↻ 重跑</button>\n <button class=\"btn btn-sm ${isAdopted ? '' : 'btn-primary'}\" id=\"prod-preview-adopt\" ${isAdopted ? 'disabled title=\"此版本已采用\"' : 'title=\"将此版本设为商品采用版本,其余转为不通过\"'} style=\"display:inline-flex; align-items:center; gap:4px;\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 12l5 5L20 6\"/></svg>\n ${isAdopted ? '已采用' : '采用此版本'}\n </button>\n <span class=\"spacer\"></span>\n <span class=\"muted-2 mono\" style=\"font-size:11px;\">~¥0.30 / 次</span>\n `;\n document.getElementById('prod-preview-rerun')?.addEventListener('click', start);\n document.getElementById('prod-preview-adopt')?.addEventListener('click', adoptPreview);\n }\n\n // 仅切预览主图,不动采用/不动商品资产\n function setPreview(idx) {\n previewIdx = idx;\n renderHistory();\n renderMain();\n saveTriState();\n }\n\n // 显式「采用」当前预览版本 · 同步商品资产 + 隐藏缺三视图徽标\n function adoptPreview() {\n if (previewIdx < 0) return;\n if (previewIdx === adoptedIdx) return;\n adoptedIdx = previewIdx;\n applyAdoption(/* fromClick */ true);\n }\n\n function applyAdoption(fromClick) {\n const ver = versions[adoptedIdx];\n if (triBadge) triBadge.hidden = true;\n const detail = ASSET_DETAILS['prod-main'];\n if (detail) {\n detail.hasTri = true;\n detail.currentVersion = ver.label;\n detail.info = [\n ['类别', '商品 · 当前项目'],\n ['名称', prodName()],\n ['三视图', '已采用 · ' + ver.label],\n ['状态', '已三视图'],\n ];\n }\n renderHistory();\n renderMain();\n saveTriState();\n if (fromClick) Shell.toast('已采用 ' + ver.label, prodName() + ' · 商品资产已更新为该版本');\n }\n\n function renderLoading() {\n img.innerHTML = `<div style=\"display:flex;flex-direction:column;gap:6px;align-items:center;\"><div class=\"spinner\"></div><span class=\"ph-frame\" style=\"font-size:10.5px;\">生成中</span></div>`;\n img.classList.remove('is-zoomable');\n img.removeAttribute('title');\n statusEl.textContent = '生成中 · 约 12s';\n foot.innerHTML = '<span class=\"muted-2 mono\" style=\"font-size:11px;\">// POST /assets/tri-view</span>';\n aigenBtn.disabled = true;\n }\n\n function finishGeneration() {\n if (!generating && !ProjectStore.getJob('stage2-product-tri')) return;\n generating = false;\n aigenBtn.disabled = false;\n const now = new Date();\n const ts = String(now.getHours()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0');\n const newVer = { ts, label: 'v' + (versions.length + 1) };\n versions.push(newVer);\n const newIdx = versions.length - 1;\n previewIdx = newIdx;\n if (adoptedIdx === -1) {\n adoptedIdx = newIdx;\n applyAdoption(/* fromClick */ false);\n } else {\n renderHistory();\n renderMain();\n Shell.toast('三视图已生成 ' + newVer.label, prodName() + ' · 预览中,满意请点「采用此版本」');\n }\n ProjectStore.finishJob('stage2-product-tri');\n ProjectStore.record('stage2.productTri.ready', { version: newVer.label });\n saveTriState();\n }\n\n function start() {\n if (generating) return;\n generating = true;\n pane.classList.add('show');\n renderLoading();\n ProjectStore.startJob('stage2-product-tri', {\n stage: 2,\n label: '商品三视图生成',\n finishAt: Date.now() + 12000,\n });\n ProjectStore.record('stage2.productTri.started', { product: prodName() });\n saveTriState();\n setTimeout(finishGeneration, 1800);\n }\n\n function resumeGenerationIfNeeded() {\n const job = ProjectStore.getJob('stage2-product-tri');\n if (!job || job.status !== 'running') return;\n generating = true;\n pane.classList.add('show');\n renderLoading();\n const remaining = Math.max(0, (job.finishAt || Date.now()) - Date.now());\n setTimeout(finishGeneration, remaining);\n }\n\n // 主图点击 → 放大查看\n img.addEventListener('click', (e) => {\n if (!img.classList.contains('is-zoomable')) return;\n if (previewIdx < 0) return;\n e.stopPropagation();\n openTriLightbox(versions[previewIdx], previewIdx === adoptedIdx, prodName());\n });\n\n aigenBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n start();\n });\n if (versions.length && previewIdx >= 0) {\n pane.classList.add('show');\n if (adoptedIdx >= 0) applyAdoption(false);\n else { renderHistory(); renderMain(); }\n }\n resumeGenerationIfNeeded();\n })();\n }\n return { init };\n})();\nStage2.init();\n\n/* ============================================================\n STAGE 3 · 故事板 · 按场分 · 切换/重跑/应用/历史\n ============================================================ */\nconst Stage3 = (function () {\n const scenes = [\n { id: 'sc1', name: '场 1 · 深夜办公桌', time: '0-15s', desc: '深夜居家办公环境,女主对镜叹气,皮肤干燥起皮特写。台灯暖光 + 屏幕冷光对比。', prompt: '中景 / 固定机位\\n光线:台灯暖光 + 屏幕冷光\\n演员:林夕(疲倦状态)\\n关键道具:面膜盒(从抽屉露半角)\\n氛围:午夜、安静、些许焦虑', adopted: 0, versions: [{ ts: '14:02', label: 'v1' }] },\n { id: 'sc2', name: '场 2 · 面膜包装/特写', time: '15-30s', desc: '女主从抽屉拿出补水面膜,包装盒微距特写 → 面膜布展开 → 30g 精华液滴落慢镜。', prompt: '特写 / 微距推镜\\n光线:柔和暖光\\n关键道具:面膜布、精华液\\n节奏:慢镜头 + 水滴回弹', adopted: 0, versions: [{ ts: '14:08', label: 'v1' }, { ts: '14:21', label: 'v2' }] },\n { id: 'sc3', name: '场 3 · 化妆台/产品定格', time: '30-45s', desc: '第二天早上,女主对镜化妆,皮肤透亮。淡入产品定格大图 + 价格标签 ¥39.9。', prompt: '中景 / 定格\\n光线:晨光 + 暖色滤镜\\n演员:林夕(精致妆面)\\n结尾:产品大图 + 价格 + 购物车浮动', adopted: 0, versions: [{ ts: '14:30', label: 'v1' }] },\n ];\n let curId = scenes[0].id;\n const savedStage3 = ProjectStore.data.stage3;\n if (savedStage3) {\n if (savedStage3.curId) curId = savedStage3.curId;\n if (Array.isArray(savedStage3.scenes)) {\n savedStage3.scenes.forEach(ss => {\n const s = scenes.find(x => x.id === ss.id);\n if (!s) return;\n if (Array.isArray(ss.versions)) s.versions = ss.versions;\n if (Number.isInteger(ss.adopted)) s.adopted = ss.adopted;\n if (typeof ss.prompt === 'string') s.prompt = ss.prompt;\n });\n }\n }\n\n function saveState() {\n ProjectStore.saveStage('stage3', {\n curId,\n scenes: scenes.map(s => ({ id: s.id, prompt: s.prompt, adopted: s.adopted, versions: s.versions })),\n });\n }\n\n function renderRow() {\n const row = document.getElementById('sb-scenes-row');\n if (!row) return;\n row.innerHTML = scenes.map(s => `<div class=\"sb-scene-thumb${s.id === curId ? ' selected' : ''}\" data-sid=\"${s.id}\">\n <div class=\"placeholder\"><span class=\"ph-frame\">${s.name.split(' · ')[1] || s.name}</span></div>\n <div class=\"nm\">${s.name.split(' · ')[0]}</div>\n <div class=\"sub\">${s.time}</div>\n </div>`).join('');\n row.querySelectorAll('.sb-scene-thumb').forEach(t => {\n t.addEventListener('click', () => { curId = t.dataset.sid; saveState(); renderAll(); });\n });\n }\n function renderMain() {\n const s = scenes.find(x => x.id === curId); if (!s) return;\n const v = s.versions[s.adopted];\n document.getElementById('sb-main-img').innerHTML = `<span class=\"ph-frame\">${s.name} · ${v.label}</span>`;\n document.getElementById('sb-side-scene').textContent = s.name.split(' · ')[0];\n const promptEdit = document.getElementById('sb-prompt-edit');\n promptEdit.textContent = s.prompt;\n promptEdit.oninput = () => {\n s.prompt = promptEdit.textContent.trim();\n saveState();\n };\n // history\n const ct = document.getElementById('sb-history-ct');\n const hist = document.getElementById('sb-history-row');\n ct.textContent = s.versions.length;\n if (s.versions.length === 0) {\n hist.innerHTML = '<div style=\"font-size: 11.5px; color: var(--black-alpha-48); padding: 12px 4px;\">// 暂无历史版本</div>';\n } else {\n hist.innerHTML = s.versions.map((vv, i) => `<div class=\"sb-history-thumb${i === s.adopted ? ' current' : ''}\" data-vi=\"${i}\">\n <div class=\"placeholder\"><span class=\"ph-frame\">${vv.label}</span></div>\n <div class=\"ts\">${vv.ts}</div>\n </div>`).join('');\n hist.querySelectorAll('.sb-history-thumb').forEach(t => {\n t.addEventListener('click', () => {\n s.adopted = +t.dataset.vi;\n ProjectStore.record('stage3.storyboard.version.selected', { scene: s.id, version: s.versions[s.adopted]?.label });\n saveState();\n renderMain();\n Shell.toast('已切换至 ' + s.versions[s.adopted].label, s.name);\n });\n });\n }\n }\n function renderAll() { renderRow(); renderMain(); }\n\n function init() {\n renderAll();\n document.getElementById('sb-rerun-btn')?.addEventListener('click', () => {\n const s = scenes.find(x => x.id === curId);\n const v = { ts: (new Date()).toTimeString().slice(0, 5), label: 'v' + (s.versions.length + 1) };\n s.versions.push(v);\n s.adopted = s.versions.length - 1;\n ProjectStore.record('stage3.storyboard.rerun', { scene: s.id, version: v.label });\n saveState();\n Shell.toast('整张重跑', s.name + ' · ' + v.label);\n renderAll();\n });\n }\n return { init };\n})();\nStage3.init();\n\n/* ============================================================\n STAGE 4 · 视频 · 详情 modal(大图 + 历史 + 采用)\n ============================================================ */\nconst Stage4 = (function () {\n const VIDEOS = {\n 'v1': { title: '场 1 · 深夜办公桌', time: '0-15s', info: [['场次', '场 1'], ['时长', '15.0s'], ['分辨率', '1080×1920 · 9:16'], ['模型', 'Seedance v2'], ['成本', '¥0.45']], versions: [{ ts: '14:32', label: 'v1' }, { ts: '14:48', label: 'v2' }, { ts: '15:02', label: 'v3' }], adopted: 2, prompt: '中景 / 固定机位\\n光线:台灯暖光 + 屏幕冷光\\n演员:林夕(疲倦状态)\\n关键道具:面膜盒(从抽屉露半角)\\n氛围:午夜、安静、些许焦虑' },\n 'v2': { title: '场 2 · 面膜包装/特写', time: '15-27s', info: [['场次', '场 2'], ['时长', '12.0s'], ['分辨率', '1080×1920 · 9:16'], ['模型', 'Seedance v2'], ['成本', '¥0.45']], versions: [{ ts: '14:35', label: 'v1' }, { ts: '14:52', label: 'v2' }], adopted: 1, prompt: '特写 / 缓推镜\\n光线:柔光顶打 + 背景虚化\\n关键道具:面膜包装、撕开瞬间\\n氛围:精致、放心、产品感' },\n 'v3': { title: '场 3 · 化妆台/产品定格', time: '27-40s', info: [['场次', '场 3'], ['时长', '13.0s'], ['分辨率', '1080×1920 · 9:16'], ['模型', 'Seedance v2'], ['成本', '¥0.45']], versions: [{ ts: '14:40', label: 'v1' }], adopted: 0, prompt: '中景 / 定格\\n光线:晨光 + 暖色滤镜\\n演员:林夕(精致妆面)\\n结尾:产品大图 + 价格 + 购物车浮动' },\n };\n const savedStage4 = ProjectStore.data.stage4;\n if (savedStage4?.videos) {\n Object.entries(savedStage4.videos).forEach(([id, sv]) => {\n if (!VIDEOS[id]) return;\n if (Array.isArray(sv.versions)) VIDEOS[id].versions = sv.versions;\n if (Number.isInteger(sv.adopted)) VIDEOS[id].adopted = sv.adopted;\n if (Number.isInteger(sv.preview)) VIDEOS[id].preview = sv.preview;\n if (typeof sv.prompt === 'string') VIDEOS[id].prompt = sv.prompt;\n });\n }\n let curVid = null;\n\n function saveState() {\n const videos = {};\n Object.entries(VIDEOS).forEach(([id, v]) => {\n videos[id] = { versions: v.versions, adopted: v.adopted, preview: v.preview, prompt: v.prompt };\n });\n ProjectStore.saveStage('stage4', { videos });\n }\n\n function getPreviewIndex(v) {\n const idx = Number.isInteger(v.preview) ? v.preview : v.adopted;\n return v.versions[idx] ? idx : Math.max(0, v.adopted || 0);\n }\n\n function openDetail(id) {\n const v = VIDEOS[id]; if (!v) return;\n curVid = id;\n document.getElementById('vd-title').textContent = v.title;\n document.getElementById('vd-sub').textContent = '// ' + v.title + ' · ' + v.time;\n const previewIdx = getPreviewIndex(v);\n const cur = v.versions[previewIdx];\n document.getElementById('vd-main-img').innerHTML = `<span class=\"ph-frame\">${v.title} · ${cur.label}</span>`;\n document.getElementById('vd-info').innerHTML = v.info.map(([k, val]) => `<div class=\"row\"><span class=\"k\">${k}</span><span class=\"v\">${val}</span></div>`).join('') + `<div class=\"row\"><span class=\"k\">当前展示</span><span class=\"v\" style=\"color: var(--heat); font-weight:600;\">${cur.label} · ${cur.ts}</span></div>`;\n const promptEl = document.getElementById('vd-prompt-edit');\n if (promptEl) {\n promptEl.textContent = v.prompt || '';\n promptEl.oninput = () => { v.prompt = promptEl.textContent.trim(); saveState(); };\n }\n document.getElementById('vd-history-ct').textContent = v.versions.length;\n const row = document.getElementById('vd-history-row');\n row.innerHTML = v.versions.map((vv, i) => `<div class=\"vd-history-thumb${i === previewIdx ? ' current' : ''}${i === v.adopted ? ' adopted' : ''}\" data-vi=\"${i}\">\n <div class=\"placeholder\"><span class=\"ph-frame\">${vv.label}</span></div>\n <div class=\"ts\">${vv.ts}</div>\n </div>`).join('');\n row.querySelectorAll('.vd-history-thumb').forEach(t => {\n t.addEventListener('click', () => {\n v.preview = +t.dataset.vi;\n ProjectStore.record('stage4.video.version.previewed', { id, version: v.versions[v.preview]?.label });\n saveState();\n openDetail(id);\n });\n });\n document.getElementById('video-detail-modal').classList.add('show');\n }\n\n function init() {\n // 根据各场实际时长(data-duration)计算总时长 + 单场平均(用于「每场 Seedance 约 n 秒」)\n const cards = document.querySelectorAll('.video-card[data-duration]');\n if (cards.length) {\n let total = 0;\n cards.forEach(c => { total += Number(c.dataset.duration) || 0; });\n const avg = Math.round(total / cards.length);\n const avgEl = document.getElementById('seedance-avg');\n const totalEl = document.getElementById('seedance-total');\n if (avgEl) avgEl.textContent = String(avg);\n if (totalEl) totalEl.textContent = String(total);\n }\n document.querySelectorAll('.video-card[data-video-id]').forEach(card => {\n card.addEventListener('click', (e) => {\n if (e.target.closest('[data-vstop]')) return;\n openDetail(card.dataset.videoId);\n });\n });\n document.getElementById('vd-adopt-btn')?.addEventListener('click', () => {\n if (!curVid) return;\n const v = VIDEOS[curVid];\n v.adopted = getPreviewIndex(v);\n v.preview = v.adopted;\n ProjectStore.record('stage4.video.version.adopted', { id: curVid, version: v.versions[v.adopted]?.label });\n saveState();\n Shell.toast('已采用 ' + v.versions[v.adopted].label, v.title + ' · 拼接将用此版');\n document.getElementById('video-detail-modal').classList.remove('show');\n });\n document.getElementById('vd-regen-btn')?.addEventListener('click', () => {\n if (!curVid) return;\n const v = VIDEOS[curVid];\n const promptEl = document.getElementById('vd-prompt-edit');\n const promptText = (promptEl?.textContent || '').trim();\n if (promptText) v.prompt = promptText;\n const nv = { ts: (new Date()).toTimeString().slice(0, 5), label: 'v' + (v.versions.length + 1) };\n v.versions.push(nv);\n v.preview = v.versions.length - 1;\n ProjectStore.record('stage4.video.rerun', { id: curVid, version: nv.label });\n saveState();\n Shell.toast('重跑中', v.title + ' · 约 30s · 新版预览中');\n openDetail(curVid);\n });\n }\n return { init };\n})();\nStage4.init();\n\n/* ============================================================\n Quota · 全局额度预检(PRD §10.3 四层)\n ============================================================ */\nwindow.Quota = (function () {\n // mock 团队/个人额度快照 - 与 team.html / account.html 数据保持一致\n const SNAP = {\n userDailyLeft: 499.55, // 个人日剩余\n userMonthlyLeft: 9837.40, // 个人月剩余\n teamMonthlyLeft: 2837.40, // 团队月剩余(月限额 3000 - 当月已用 162.60)\n teamBalance: 327.40, // 团队总余额\n };\n\n function buildChecks(est, demoBlock) {\n const need = est * 1.2; // PRD §10.3 任务预估 × 1.2\n const layers = [\n { name: '个人日剩余', left: SNAP.userDailyLeft, need },\n { name: '个人月剩余', left: SNAP.userMonthlyLeft, need },\n { name: '团队月剩余', left: SNAP.teamMonthlyLeft, need },\n { name: '团队总余额', left: demoBlock ? 0.50 : SNAP.teamBalance, need },\n ];\n return layers.map((l, i) => ({\n ...l,\n ok: l.left >= l.need,\n idx: i + 1,\n }));\n }\n\n function fmt(n) { return '¥' + n.toFixed(2).replace(/\\B(?=(\\d{3})+(?!\\d))/g, ','); }\n\n function preflight({ stage, est = 0, hash = '', demo = '', force = false } = {}) {\n const isDemoBlock = demo === 'block';\n const checks = buildChecks(est, isDemoBlock);\n const allOk = checks.every(c => c.ok);\n\n // 渲染 stage 行\n document.getElementById('quota-stage-row').innerHTML =\n '<span style=\"font-family:var(--font-mono);font-size:11.5px;color:var(--black-alpha-48);letter-spacing:.02em;\">// 任务</span> <strong>' + stage + '</strong> · 预估扣费 <strong style=\"color:var(--heat);font-variant-numeric:tabular-nums;\">' + fmt(est) + '</strong> <span style=\"font-family:var(--font-mono);font-size:11px;color:var(--black-alpha-48);\">(×1.2 预留 = ' + fmt(est * 1.2) + ')</span>';\n\n // 渲染 4 行检查\n document.getElementById('quota-checks').innerHTML = checks.map(c => `\n <div style=\"display:grid;grid-template-columns:22px 1fr auto;gap:8px;align-items:baseline;font-size:12.5px;padding:8px 10px;background:var(--background-lighter);border:1px solid var(--border-faint);border-radius:var(--r-sm);\">\n <span style=\"width:18px;height:18px;border-radius:50%;display:grid;place-items:center;font-family:var(--font-mono);font-size:10.5px;font-weight:600;background:${c.ok ? 'rgba(66,195,102,.12)' : 'rgba(235,52,36,.12)'};color:${c.ok ? 'var(--accent-forest)' : 'var(--accent-crimson)'};\">${c.idx}</span>\n <span><strong style=\"color:var(--accent-black);font-weight:500;\">${c.name}</strong> <span style=\"color:var(--black-alpha-48);font-family:var(--font-mono);font-size:11px;\">${fmt(c.left)} ≥ ${fmt(c.need)}</span></span>\n <span style=\"font-family:var(--font-mono);font-size:11px;color:${c.ok ? 'var(--accent-forest)' : 'var(--accent-crimson)'};font-weight:600;\">${c.ok ? '✓ 通过' : '✗ 不足'}</span>\n </div>\n `).join('');\n\n // 标题 + footer 按钮\n const ic = document.getElementById('quota-ic');\n const title = document.getElementById('quota-title');\n const sub = document.getElementById('quota-sub');\n const tip = document.getElementById('quota-block-tip');\n const confirmBtn = document.getElementById('quota-confirm');\n const topupBtn = document.getElementById('quota-topup');\n\n if (allOk) {\n ic.style.background = 'rgba(66,195,102,.12)';\n ic.style.color = 'var(--accent-forest)';\n ic.innerHTML = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M20 6L9 17l-5-5\"/></svg>';\n title.firstChild.textContent = '额度预检通过';\n sub.textContent = '// 4 层检查 · 全部通过';\n tip.style.display = 'none';\n confirmBtn.style.display = '';\n topupBtn.style.display = 'none';\n confirmBtn.onclick = () => {\n Shell.closeModal('quota-bg');\n Shell.toast('已确认扣费', stage + ' · 预估 ' + fmt(est));\n if (hash) location.hash = hash;\n };\n } else {\n ic.style.background = 'rgba(235,52,36,.12)';\n ic.style.color = 'var(--accent-crimson)';\n ic.innerHTML = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M12 8v4M12 16h.01\"/></svg>';\n title.firstChild.textContent = '额度预检未通过';\n sub.textContent = '// ' + checks.filter(c => !c.ok).length + ' 层不通过 · 任务已拦截';\n tip.style.display = '';\n confirmBtn.style.display = 'none';\n topupBtn.style.display = '';\n }\n\n Shell.openModal('quota-bg');\n }\n\n return { preflight };\n})();\n\n/* ============================================================\n 三视图 · 放大查看 lightbox · setupProdPreview 共用\n ============================================================ */\nfunction openTriLightbox(ver, isAdopted, prodName) {\n let bg = document.getElementById('tri-lightbox-bg');\n if (!bg) {\n bg = document.createElement('div');\n bg.id = 'tri-lightbox-bg';\n bg.className = 'modal-bg';\n bg.innerHTML = `\n <div class=\"tri-lightbox\" role=\"dialog\" aria-label=\"三视图放大查看\">\n <button class=\"tri-lightbox-close\" type=\"button\" aria-label=\"关闭\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M18 6L6 18M6 6l12 12\"/></svg>\n </button>\n <div class=\"tri-lightbox-head\">\n // 三视图(正/侧/背) · <span class=\"lb-ver\" id=\"tri-lightbox-label\">v1</span>\n <span class=\"lb-tag\" id=\"tri-lightbox-tag\" hidden>已采用</span>\n </div>\n <div class=\"placeholder tri-lightbox-img\" id=\"tri-lightbox-img\"></div>\n <div class=\"tri-lightbox-foot\">\n <span id=\"tri-lightbox-meta\">// 生成于 --:--</span>\n <span class=\"spc\"></span>\n <span><kbd>Esc</kbd> 关闭</span>\n </div>\n </div>\n `;\n document.body.appendChild(bg);\n bg.addEventListener('click', (e) => {\n if (e.target === bg) Shell.closeModal('tri-lightbox-bg');\n });\n bg.querySelector('.tri-lightbox-close')?.addEventListener('click', () => {\n Shell.closeModal('tri-lightbox-bg');\n });\n }\n bg.querySelector('#tri-lightbox-img').innerHTML =\n `<span class=\"ph-frame\">${prodName} · 三视图(正/侧/背) · ${ver.label}</span>`;\n bg.querySelector('#tri-lightbox-label').textContent = ver.label;\n const tag = bg.querySelector('#tri-lightbox-tag');\n tag.hidden = !isAdopted;\n bg.querySelector('#tri-lightbox-meta').textContent = `// 生成于 ${ver.ts}`;\n Shell.openModal('tri-lightbox-bg');\n}\n\n/* ═══════════════════════════════════════════════════════════\n STAGE 5 · 时间轴编辑器交互(剪映式)\n select / seek / drag-playhead / play-pause / zoom / del / split / trim\n ═══════════════════════════════════════════════════════════ */\n(function initTimelineEditor() {\n const $tl = document.getElementById('ed-timeline');\n if (!$tl) return;\n const $ruler = document.getElementById('ed-ruler');\n const $playhead = document.getElementById('ed-playhead');\n const $laneV = document.getElementById('ed-lane-video');\n const $laneS = document.getElementById('ed-lane-subtitle');\n const $playBtn = document.getElementById('ed-play-btn');\n const $playIcon = document.getElementById('ed-play-icon');\n const $prevBtn = document.getElementById('ed-prev-btn');\n const $nextBtn = document.getElementById('ed-next-btn');\n const $splitBtn = document.getElementById('ed-split-btn');\n const $copyBtn = document.getElementById('ed-copy-btn');\n const $delBtn = document.getElementById('ed-del-btn');\n const $zoom = document.getElementById('ed-zoom-input');\n const $curTime = document.getElementById('ed-cur-time');\n const $totalTime= document.getElementById('ed-total-time');\n const $insName = document.getElementById('ed-inspect-name');\n const $insStart = document.getElementById('ed-inspect-start');\n const $insDur = document.getElementById('ed-inspect-dur');\n const $canvasLb = document.getElementById('ed-canvas-label');\n\n const TOTAL = 15;\n let currentTime = 0;\n let selectedEl = null;\n let playing = false;\n let rafId = null;\n let lastTs = 0;\n\n const PLAY_SVG = '<path d=\"M5 4l7 4-7 4z\" fill=\"currentColor\"/>';\n const PAUSE_SVG = '<path d=\"M4 3h3v10H4zM9 3h3v10H9z\" fill=\"currentColor\"/>';\n\n function fmtTime(s) {\n s = Math.max(0, Math.min(TOTAL, s));\n const m = Math.floor(s / 60);\n const sec = s - m * 60;\n return String(m).padStart(2,'0') + ':' + sec.toFixed(2).padStart(5,'0');\n }\n // 时间轴数据 · data-start(时间线起点) + data-dur(当前时长) + data-max(源最大) + data-in(源素材入点)\n const MIN_DUR = 0.2;\n const $laneB = document.querySelector('#ed-timeline .bgm-track .lane');\n const lanes = { video: $laneV, subtitle: $laneS, bgm: $laneB };\n const ALIGN_EPS = 0.12;\n const $alignGuide = document.createElement('span');\n $alignGuide.className = 'tl-align-guide';\n $tl.appendChild($alignGuide);\n const D = (c) => Number(c.dataset.dur || 1);\n const M = (c) => Number(c.dataset.max || 1);\n const IN = (c) => Number(c.dataset.in || 0);\n function clipDur(c) { return D(c); }\n function compactStart(c) {\n let acc = 0;\n for (const sib of c.parentElement.querySelectorAll('.clip')) {\n if (sib === c) return acc;\n acc += D(sib);\n }\n return 0;\n }\n function clipStart(c) {\n const start = Number(c.dataset.start);\n return Number.isFinite(start) ? start : compactStart(c);\n }\n function clipEnd(c) { return clipStart(c) + D(c); }\n function sourceDur(c) { return Math.max(M(c), D(c), MIN_DUR); }\n function clampSourceIn(c, sourceIn = IN(c)) {\n const maxIn = Math.max(0, sourceDur(c) - D(c));\n return Math.max(0, Math.min(maxIn, sourceIn));\n }\n function syncVideoPreview(c) {\n if (!c.classList.contains('video')) return;\n const dur = Math.max(D(c), MIN_DUR);\n const srcDur = Math.max(sourceDur(c), dur);\n const sourceIn = clampSourceIn(c);\n c.dataset.in = String(sourceIn);\n c.style.setProperty('--src-width', (srcDur / dur * 100) + '%');\n c.style.setProperty('--src-offset', (srcDur ? (-(sourceIn / srcDur) * 100) : 0) + '%');\n }\n function freezeLaneStarts(lane) {\n let acc = 0;\n for (const clip of lane.querySelectorAll('.clip')) {\n const explicit = Number(clip.dataset.start);\n const start = Number.isFinite(explicit) ? explicit : acc;\n clip.dataset.start = String(start);\n acc = start + D(clip);\n }\n }\n function compactLane(lane) {\n let acc = 0;\n for (const clip of lane.querySelectorAll('.clip')) {\n clip.dataset.start = String(acc);\n acc += D(clip);\n }\n layoutLane(lane);\n }\n function snapLane(lane) {\n lane.classList.add('is-snapping');\n compactLane(lane);\n window.setTimeout(() => lane.classList.remove('is-snapping'), 180);\n }\n function settleLane(lane) {\n if (lane === lanes.video) snapLane(lane);\n else layoutLane(lane);\n }\n // 绝对布局:默认紧贴排列;trim 后允许保留被裁剪端的可见空隙\n function layoutLane(lane) {\n let acc = 0;\n for (const clip of lane.querySelectorAll('.clip')) {\n const dur = D(clip);\n const explicit = Number(clip.dataset.start);\n const start = Number.isFinite(explicit) ? explicit : acc;\n clip.style.left = (start / TOTAL * 100) + '%';\n clip.style.width = (dur / TOTAL * 100) + '%';\n syncVideoPreview(clip);\n acc = start + dur;\n }\n }\n function videoBoundaries() {\n const points = [];\n $laneV.querySelectorAll('.clip.video').forEach(c => {\n points.push(clipStart(c), clipEnd(c));\n });\n return points;\n }\n function nearestVideoBoundary(edges) {\n let best = null;\n const points = videoBoundaries();\n edges.forEach((edge, edgeIndex) => {\n points.forEach(time => {\n const diff = Math.abs(edge - time);\n if (diff <= ALIGN_EPS && (!best || diff < best.diff)) {\n best = { edge, edgeIndex, time, diff };\n }\n });\n });\n return best;\n }\n function showAlignGuideAt(time) {\n const laneRect = $laneV.getBoundingClientRect();\n const tlRect = $tl.getBoundingClientRect();\n const rulerRect = $ruler.getBoundingClientRect();\n const lastLane = $laneB || $laneS || $laneV;\n const lastRect = lastLane.getBoundingClientRect();\n $alignGuide.style.left = (laneRect.left + (time / TOTAL) * laneRect.width - tlRect.left) + 'px';\n $alignGuide.style.top = (rulerRect.top - tlRect.top) + 'px';\n $alignGuide.style.height = (lastRect.bottom - rulerRect.top) + 'px';\n $alignGuide.classList.add('show');\n }\n function hideAlignGuide() {\n $alignGuide.classList.remove('show');\n }\n function guideEdgeToVideo(edge) {\n const match = nearestVideoBoundary([edge]);\n if (!match) {\n hideAlignGuide();\n return null;\n }\n showAlignGuideAt(match.time);\n return match.time;\n }\n function guideClipToVideo(start, dur) {\n const safeDur = Math.max(MIN_DUR, Math.min(dur, TOTAL));\n const clampedStart = Math.max(0, Math.min(TOTAL - safeDur, start));\n const match = nearestVideoBoundary([clampedStart, clampedStart + safeDur]);\n if (!match) {\n hideAlignGuide();\n return clampedStart;\n }\n const nextStart = match.edgeIndex === 0 ? match.time : match.time - safeDur;\n showAlignGuideAt(match.time);\n return Math.max(0, Math.min(TOTAL - safeDur, nextStart));\n }\n function ensureDropGhost(lane) {\n let ghost = lane.querySelector('.tl-insert-ghost');\n if (!ghost) {\n ghost = document.createElement('span');\n ghost.className = 'tl-insert-ghost';\n lane.appendChild(ghost);\n }\n return ghost;\n }\n function removeDropGhost(lane) {\n lane.querySelector('.tl-insert-ghost')?.remove();\n }\n function placeDropGhost(ghost, start, dur) {\n ghost.style.left = (start / TOTAL * 100) + '%';\n ghost.style.width = (dur / TOTAL * 100) + '%';\n }\n function insertIndexForCenter(siblings, centerT) {\n let acc = 0;\n for (let i = 0; i < siblings.length; i++) {\n const mid = acc + D(siblings[i]) / 2;\n if (centerT < mid) return i;\n acc += D(siblings[i]);\n }\n return siblings.length;\n }\n function previewReorder(lane, dragged, insertIndex) {\n const siblings = [...lane.querySelectorAll('.clip')].filter(c => c !== dragged);\n const ghost = ensureDropGhost(lane);\n let acc = 0;\n for (let i = 0; i <= siblings.length; i++) {\n if (i === insertIndex) {\n placeDropGhost(ghost, acc, D(dragged));\n acc += D(dragged);\n }\n const sibling = siblings[i];\n if (sibling) {\n sibling.dataset.start = String(acc);\n sibling.style.left = (acc / TOTAL * 100) + '%';\n sibling.style.width = (D(sibling) / TOTAL * 100) + '%';\n syncVideoPreview(sibling);\n acc += D(sibling);\n }\n }\n }\n function commitReorder(lane, dragged, insertIndex) {\n const siblings = [...lane.querySelectorAll('.clip')].filter(c => c !== dragged);\n dragged.remove();\n if (insertIndex >= siblings.length) lane.appendChild(dragged);\n else lane.insertBefore(dragged, siblings[insertIndex]);\n }\n function compactAll() {\n Object.values(lanes).forEach(l => l && compactLane(l));\n }\n function clipAtTimeOnTrack(track, t) {\n const lane = lanes[track];\n if (!lane) return null;\n for (const c of lane.querySelectorAll('.clip')) {\n if (t >= clipStart(c) && t < clipEnd(c)) return c;\n }\n return null;\n }\n\n function updateTimeUI() {\n $curTime.textContent = fmtTime(currentTime);\n $totalTime.textContent = fmtTime(TOTAL);\n $playhead.style.left = (currentTime / TOTAL * 100) + '%';\n const v = clipAtTimeOnTrack('video', currentTime);\n if ($canvasLb) {\n $canvasLb.textContent = v\n ? '9:16 预览 · 1080×1920 · ' + (v.dataset.label || '')\n : '9:16 预览 · 1080×1920';\n }\n updateActionButtons();\n }\n\n function selectClip(clip) {\n document.querySelectorAll('#ed-timeline .clip.selected').forEach(c => {\n c.classList.remove('selected');\n c.querySelectorAll('[data-trim]').forEach(t => t.remove());\n });\n selectedEl = clip;\n if (clip) {\n clip.classList.add('selected');\n // 三轨皆可剪 · 任意选中片段都加 trim 把手\n const l = document.createElement('span'); l.className = 'trim-l'; l.dataset.trim = 'l';\n const r = document.createElement('span'); r.className = 'trim-r'; r.dataset.trim = 'r';\n clip.prepend(l); clip.appendChild(r);\n }\n updateInspector();\n updateActionButtons();\n }\n function updateInspector() {\n if (!selectedEl) {\n $insName.textContent = '未选';\n $insStart.value = '—';\n $insDur.value = '—';\n return;\n }\n const t = selectedEl.dataset.track;\n const num = selectedEl.querySelector('.num')?.textContent.trim();\n const label = selectedEl.dataset.label || '';\n $insName.textContent = (t === 'video' ? '镜 ' + num : t === 'subtitle' ? '字幕' : 'BGM')\n + (label ? ' · ' + label.slice(0, 12) : '');\n $insStart.value = fmtTime(clipStart(selectedEl));\n $insDur.value = clipDur(selectedEl).toFixed(2) + 's';\n }\n function updateActionButtons() {\n const has = !!selectedEl;\n $delBtn.disabled = !has;\n $copyBtn.disabled = !has;\n if (has) {\n const s = clipStart(selectedEl), e = clipEnd(selectedEl);\n $splitBtn.disabled = !(currentTime > s + 0.05 && currentTime < e - 0.05);\n } else {\n $splitBtn.disabled = true;\n }\n }\n\n function bindClipClicks() {\n document.querySelectorAll('#ed-timeline .clip').forEach(clip => {\n if (clip.dataset.boundClick) return;\n clip.dataset.boundClick = '1';\n clip.addEventListener('click', (e) => {\n if (e.target.closest('[data-trim]')) return;\n if (_bodyDragMoved) return; // 拖动重排刚结束 · 抑制 click\n e.stopPropagation();\n selectClip(clip);\n if (clip.dataset.track === 'video') {\n currentTime = clipStart(clip);\n updateTimeUI();\n }\n });\n });\n }\n\n function laneSeek(lane, ev) {\n if (ev.target.closest('.clip') || ev.target.closest('.playhead') || ev.target.closest('[data-trim]')) return;\n const rect = lane.getBoundingClientRect();\n const pct = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width));\n currentTime = pct * TOTAL;\n updateTimeUI();\n }\n $laneV.addEventListener('click', e => laneSeek($laneV, e));\n\n $ruler.addEventListener('click', (e) => {\n const rect = $ruler.getBoundingClientRect();\n const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));\n currentTime = pct * TOTAL;\n updateTimeUI();\n });\n\n let dragging = false;\n let playheadDragOffset = 0;\n function playheadCenterX() {\n const rect = $laneS.getBoundingClientRect();\n return rect.left + (currentTime / TOTAL) * rect.width;\n }\n function seekPlayhead(clientX) {\n const rect = $laneS.getBoundingClientRect();\n const pct = Math.max(0, Math.min(1, (clientX - playheadDragOffset - rect.left) / rect.width));\n currentTime = pct * TOTAL;\n updateTimeUI();\n }\n function startPlayheadDrag(e) {\n e.preventDefault();\n e.stopPropagation();\n dragging = true;\n playheadDragOffset = e.clientX - playheadCenterX();\n if (playing) pause();\n $playhead.classList.add('is-dragging');\n $tl.classList.add('is-dragging-playhead');\n }\n $playhead.addEventListener('mousedown', startPlayheadDrag);\n document.addEventListener('mousemove', (e) => {\n if (!dragging) return;\n seekPlayhead(e.clientX);\n });\n document.addEventListener('mouseup', () => {\n if (!dragging) return;\n dragging = false;\n playheadDragOffset = 0;\n $playhead.classList.remove('is-dragging');\n $tl.classList.remove('is-dragging-playhead');\n });\n\n function play() {\n if (playing) return;\n if (currentTime >= TOTAL - 0.01) currentTime = 0;\n playing = true;\n $playIcon.innerHTML = PAUSE_SVG;\n $playBtn.classList.add('is-playing');\n lastTs = performance.now();\n rafId = requestAnimationFrame(tick);\n }\n function pause() {\n if (!playing) return;\n playing = false;\n $playIcon.innerHTML = PLAY_SVG;\n $playBtn.classList.remove('is-playing');\n if (rafId) cancelAnimationFrame(rafId);\n rafId = null;\n }\n function tick(ts) {\n if (!playing) return;\n const dt = (ts - lastTs) / 1000;\n lastTs = ts;\n currentTime = Math.min(TOTAL, currentTime + dt);\n updateTimeUI();\n if (currentTime >= TOTAL) { pause(); return; }\n rafId = requestAnimationFrame(tick);\n }\n $playBtn.addEventListener('click', () => playing ? pause() : play());\n\n const FRAME = 1 / 30;\n $prevBtn.addEventListener('click', () => {\n pause();\n currentTime = Math.max(0, currentTime - FRAME);\n updateTimeUI();\n });\n $nextBtn.addEventListener('click', () => {\n pause();\n currentTime = Math.min(TOTAL, currentTime + FRAME);\n updateTimeUI();\n });\n\n document.addEventListener('keydown', (e) => {\n const stage5 = document.querySelector('section.stage.active[data-stage-pane=\"5\"]');\n if (!stage5) return;\n if (e.target.matches('input, textarea, [contenteditable]')) return;\n if (e.code === 'Space') { e.preventDefault(); playing ? pause() : play(); }\n else if (e.key === 'ArrowLeft') { e.preventDefault(); $prevBtn.click(); }\n else if (e.key === 'ArrowRight') { e.preventDefault(); $nextBtn.click(); }\n else if (e.key === 'Delete' || e.key === 'Backspace') {\n if (selectedEl) { e.preventDefault(); deleteSelected(); }\n }\n });\n\n $zoom.addEventListener('input', () => {\n const pct = Number($zoom.value);\n document.querySelectorAll('#ed-timeline .lane, #ed-timeline #ed-ruler').forEach(l => {\n l.style.minWidth = (pct === 100 ? '' : (pct + '%'));\n });\n });\n\n function deleteSelected() {\n if (!selectedEl) return;\n const lane = selectedEl.parentElement;\n const next = selectedEl.nextElementSibling?.classList.contains('clip')\n ? selectedEl.nextElementSibling\n : selectedEl.previousElementSibling?.classList.contains('clip')\n ? selectedEl.previousElementSibling\n : null;\n selectedEl.remove();\n settleLane(lane);\n if (next) selectClip(next);\n else { selectedEl = null; updateInspector(); updateActionButtons(); }\n renumberVideo();\n updateTimeUI();\n }\n $delBtn.addEventListener('click', deleteSelected);\n\n $copyBtn.addEventListener('click', () => {\n if (!selectedEl) return;\n const dup = selectedEl.cloneNode(true);\n dup.classList.remove('selected');\n dup.querySelectorAll('[data-trim]').forEach(t => t.remove());\n delete dup.dataset.boundClick;\n selectedEl.after(dup);\n if (selectedEl.parentElement !== lanes.video) {\n dup.dataset.start = String(Math.max(0, Math.min(TOTAL - D(dup), clipEnd(selectedEl))));\n }\n settleLane(selectedEl.parentElement);\n bindClipClicks();\n renumberVideo();\n selectClip(dup);\n });\n\n $splitBtn.addEventListener('click', () => {\n if (!selectedEl) return;\n const s = clipStart(selectedEl), e = clipEnd(selectedEl);\n if (!(currentTime > s + 0.05 && currentTime < e - 0.05)) return;\n const cutOff = currentTime - s; // 距 clip 起始的偏移\n const origDur = D(selectedEl);\n const leftDur = cutOff;\n const rightDur = origDur - cutOff;\n // 左半:dur=max=leftDur(切开后双方各自独立 · 不能再恢复跨越切点)\n selectedEl.dataset.dur = String(leftDur);\n selectedEl.dataset.max = String(leftDur);\n selectedEl.dataset.in = '0';\n selectedEl.dataset.start = String(s);\n const right = selectedEl.cloneNode(true);\n right.classList.remove('selected');\n right.querySelectorAll('[data-trim]').forEach(t => t.remove());\n delete right.dataset.boundClick;\n right.dataset.dur = String(rightDur);\n right.dataset.max = String(rightDur);\n right.dataset.in = '0';\n right.dataset.start = String(s + leftDur);\n // 右半若是视频片段 · 重新生成胶卷帧条数量(按时长成比例)\n if (right.classList.contains('video')) {\n const oldFrames = right.querySelector('.frames');\n if (oldFrames) {\n const n = Math.max(2, Math.round(rightDur * 1.5));\n oldFrames.innerHTML = '<span class=\"fr\"></span>'.repeat(n);\n }\n const oldFramesL = selectedEl.querySelector('.frames');\n if (oldFramesL) {\n const n = Math.max(2, Math.round(leftDur * 1.5));\n oldFramesL.innerHTML = '<span class=\"fr\"></span>'.repeat(n);\n }\n }\n selectedEl.after(right);\n layoutLane(selectedEl.parentElement);\n bindClipClicks();\n renumberVideo();\n selectClip(right);\n });\n\n function renumberVideo() {\n $laneV.querySelectorAll('.clip.video').forEach((c, i) => {\n const num = c.querySelector('.num');\n if (num) num.textContent = String(i + 1);\n });\n }\n\n // Trim 把手 · 三轨通用 · 左右边界按真实裁剪方向反馈\n document.addEventListener('mousedown', (e) => {\n const handle = e.target.closest('[data-trim]');\n if (!handle || !selectedEl) return;\n e.preventDefault(); e.stopPropagation();\n const side = handle.dataset.trim;\n const lane = selectedEl.parentElement;\n const isVideoTrack = lane === lanes.video;\n freezeLaneStarts(lane);\n const laneRect = lane.getBoundingClientRect();\n const startMouseX = e.clientX;\n const startStart = clipStart(selectedEl);\n const startDur = D(selectedEl);\n const startEnd = startStart + startDur;\n const startIn = clampSourceIn(selectedEl);\n const startSourceDur = sourceDur(selectedEl);\n const startOut = Math.max(0, startSourceDur - startIn - startDur);\n const prevClip = selectedEl.previousElementSibling?.classList.contains('clip') ? selectedEl.previousElementSibling : null;\n const nextClip = selectedEl.nextElementSibling?.classList.contains('clip') ? selectedEl.nextElementSibling : null;\n const prevEnd = prevClip ? clipEnd(prevClip) : 0;\n const nextStart = nextClip ? clipStart(nextClip) : TOTAL;\n let didTrim = false;\n\n function onMove(ev) {\n const dx = ev.clientX - startMouseX;\n if (Math.abs(dx) > 1) didTrim = true;\n const dt = (dx / laneRect.width) * TOTAL;\n if (!isVideoTrack) {\n if (side === 'l') {\n const maxStart = startEnd - MIN_DUR;\n let newStart = Math.max(0, Math.min(maxStart, startStart + dt));\n const guidedStart = guideEdgeToVideo(newStart);\n if (guidedStart !== null) newStart = Math.max(0, Math.min(maxStart, guidedStart));\n selectedEl.dataset.start = String(newStart);\n selectedEl.dataset.dur = String(Math.max(MIN_DUR, startEnd - newStart));\n } else {\n const minEnd = startStart + MIN_DUR;\n let newEnd = Math.max(minEnd, Math.min(TOTAL, startEnd + dt));\n const guidedEnd = guideEdgeToVideo(newEnd);\n if (guidedEnd !== null) newEnd = Math.max(minEnd, Math.min(TOTAL, guidedEnd));\n selectedEl.dataset.start = String(startStart);\n selectedEl.dataset.dur = String(Math.max(MIN_DUR, newEnd - startStart));\n }\n layoutLane(lane);\n updateInspector();\n updateActionButtons();\n return;\n }\n hideAlignGuide();\n if (side === 'l') {\n const minStart = Math.max(prevEnd, startStart - startIn);\n const maxStart = startEnd - MIN_DUR;\n const newStart = Math.max(minStart, Math.min(maxStart, startStart + dt));\n const newDur = Math.max(MIN_DUR, startEnd - newStart);\n const newIn = Math.max(0, Math.min(startSourceDur - newDur, startIn + (newStart - startStart)));\n selectedEl.dataset.start = String(newStart);\n selectedEl.dataset.dur = String(newDur);\n selectedEl.dataset.in = String(newIn);\n } else {\n const minEnd = startStart + MIN_DUR;\n const maxEnd = Math.min(nextStart, startEnd + startOut, TOTAL);\n const newEnd = Math.max(minEnd, Math.min(maxEnd, startEnd + dt));\n selectedEl.dataset.start = String(startStart);\n selectedEl.dataset.dur = String(newEnd - startStart);\n selectedEl.dataset.in = String(startIn);\n }\n layoutLane(lane);\n updateInspector();\n updateActionButtons();\n }\n function onUp() {\n document.removeEventListener('mousemove', onMove);\n document.removeEventListener('mouseup', onUp);\n document.body.style.cursor = '';\n hideAlignGuide();\n if (isVideoTrack) {\n snapLane(lane);\n if (didTrim && selectedEl) {\n currentTime = side === 'l' ? clipStart(selectedEl) : clipEnd(selectedEl);\n }\n updateInspector();\n updateTimeUI();\n } else {\n layoutLane(lane);\n updateInspector();\n updateActionButtons();\n }\n }\n document.body.style.cursor = 'ew-resize';\n document.addEventListener('mousemove', onMove);\n document.addEventListener('mouseup', onUp);\n });\n\n // 拖拽选中片段身体 · 在同轨内重排位置\n let _bodyDragMoved = false;\n document.addEventListener('mousedown', (e) => {\n const clip = e.target.closest('#ed-timeline .clip');\n if (!clip) return;\n if (e.target.closest('[data-trim]')) return;\n if (e.target.closest('.ph-grab')) return;\n e.preventDefault();\n _bodyDragMoved = false;\n if (clip !== selectedEl) selectClip(clip);\n const lane = clip.parentElement;\n const isVideoTrack = lane === lanes.video;\n freezeLaneStarts(lane);\n const laneRect = lane.getBoundingClientRect();\n const startMouseX = e.clientX;\n const startStartT = clipStart(clip);\n const clipDuration = D(clip);\n let dropIndex = null;\n\n function onMove(ev) {\n const dx = ev.clientX - startMouseX;\n if (!_bodyDragMoved && Math.abs(dx) < 5) return;\n _bodyDragMoved = true;\n clip.classList.add('dragging');\n const dt = (dx / laneRect.width) * TOTAL;\n if (!isVideoTrack) {\n const previewStart = guideClipToVideo(startStartT + dt, clipDuration);\n clip.dataset.start = String(previewStart);\n clip.style.left = (previewStart / TOTAL * 100) + '%';\n clip.style.width = (clipDuration / TOTAL * 100) + '%';\n updateInspector();\n updateActionButtons();\n document.body.style.cursor = 'grabbing';\n return;\n }\n hideAlignGuide();\n lane.classList.add('is-reordering');\n const newCenter = Math.max(clipDuration / 2, Math.min(TOTAL - clipDuration / 2, startStartT + clipDuration / 2 + dt));\n const siblings = [...lane.querySelectorAll('.clip')].filter(c => c !== clip);\n dropIndex = insertIndexForCenter(siblings, newCenter);\n previewReorder(lane, clip, dropIndex);\n const previewStart = Math.max(0, Math.min(TOTAL - clipDuration, newCenter - clipDuration / 2));\n clip.dataset.start = String(previewStart);\n clip.style.left = (previewStart / TOTAL * 100) + '%';\n clip.style.width = (clipDuration / TOTAL * 100) + '%';\n syncVideoPreview(clip);\n document.body.style.cursor = 'grabbing';\n }\n function onUp(ev) {\n document.removeEventListener('mousemove', onMove);\n document.removeEventListener('mouseup', onUp);\n removeDropGhost(lane);\n lane.classList.remove('is-reordering');\n clip.classList.remove('dragging');\n document.body.style.cursor = '';\n hideAlignGuide();\n if (!_bodyDragMoved) return;\n if (!isVideoTrack) {\n layoutLane(lane);\n selectClip(clip);\n updateInspector();\n updateActionButtons();\n setTimeout(() => { _bodyDragMoved = false; }, 50);\n return;\n }\n if (dropIndex === null) {\n const dx = ev.clientX - startMouseX;\n const dt = (dx / laneRect.width) * TOTAL;\n const newCenter = Math.max(clipDuration / 2, Math.min(TOTAL - clipDuration / 2, startStartT + clipDuration / 2 + dt));\n const siblings = [...lane.querySelectorAll('.clip')].filter(c => c !== clip);\n dropIndex = insertIndexForCenter(siblings, newCenter);\n }\n commitReorder(lane, clip, dropIndex);\n snapLane(lane);\n if (lane === lanes.video) renumberVideo();\n selectClip(clip);\n updateTimeUI();\n setTimeout(() => { _bodyDragMoved = false; }, 50);\n }\n document.addEventListener('mousemove', onMove);\n document.addEventListener('mouseup', onUp);\n });\n\n // 初始化:磁吸布局 + 绑定\n compactAll();\n bindClipClicks();\n updateTimeUI();\n})();\n</script>\n<script src=\"/exact/assets/api-bridge.js?v=2026060114\"></script>\n</body>\n</html>\n",
|
||
"platformCover": "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta name=\"x-airshelf-exact-source\" content=\"platform-cover.html\">\n<meta charset=\"utf-8\">\n<title>平台套图 · Airshelf</title>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"/exact/assets/restraint.css?v=2026060101\">\n<style>\n .app { height: 100vh; overflow: hidden; }\n main { display: flex; flex-direction: column; min-height: 0; }\n #page-content { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0; }\n\n .pc-layout {\n flex: 1; min-height: 0;\n display: grid;\n grid-template-columns: 260px 1fr;\n transition: grid-template-columns var(--t-base);\n }\n .pc-layout.side-collapsed { grid-template-columns: 0 1fr; }\n @media (max-width: 1280px) {\n .pc-layout { grid-template-columns: 240px 1fr; }\n .pc-layout.side-collapsed { grid-template-columns: 0 1fr; }\n }\n\n /* ─── 主区: flat 双区 · 用 border 分隔 ─── */\n .pc-main {\n display: flex; flex-direction: column;\n min-height: 0;\n overflow: hidden;\n }\n /* 主区头部 · toolbar 风格 (跟 image-optimize 一致) */\n .pc-main-h {\n flex-shrink: 0;\n display: flex; align-items: center; gap: 10px;\n padding: 12px 28px;\n border-bottom: 1px solid var(--border-faint);\n background: var(--surface);\n }\n .pc-main-h .cur-title {\n display: flex; align-items: baseline; gap: 8px;\n min-width: 0; max-width: 50%;\n }\n .pc-main-h .cur-title .crumb {\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48); letter-spacing: .04em;\n flex-shrink: 0;\n }\n .pc-main-h .cur-title .nm {\n font-size: 15px; font-weight: 600;\n color: var(--accent-black);\n letter-spacing: -.005em;\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n }\n .pc-main-h .cur-title .nm.placeholder {\n font-weight: 400; font-size: 13px;\n color: var(--black-alpha-48);\n }\n .pc-main-h .spacer { flex: 1; }\n .pc-main-h .side-restore-btn {\n height: 32px;\n padding: 0 10px;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--black-alpha-72);\n font-family: inherit;\n font-size: 12.5px;\n cursor: pointer;\n }\n .pc-main-h .side-restore-btn:hover { border-color: var(--heat-20); color: var(--heat); }\n .pc-main-h .side-restore-btn[hidden] { display: none; }\n .pc-main-h .side-restore-btn svg { width: 14px; height: 14px; }\n .pc-main-h .search-btn {\n width: 32px; height: 32px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--black-alpha-72);\n cursor: pointer;\n display: grid; place-items: center;\n transition: border-color var(--t-base), color var(--t-base);\n }\n .pc-main-h .search-btn:hover { border-color: var(--heat-20); color: var(--heat); }\n .pc-main-h .search-btn svg { width: 14px; height: 14px; }\n .pc-main-h .tb-chip {\n display: inline-flex; align-items: center; gap: 6px;\n height: 32px; padding: 0 10px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n font-size: 12.5px; color: var(--black-alpha-72);\n font-family: inherit; cursor: pointer;\n transition: border-color var(--t-base), color var(--t-base);\n }\n .pc-main-h .tb-chip:hover { border-color: var(--heat-20); color: var(--heat); }\n .pc-main-h .tb-chip svg { width: 10px; height: 10px; opacity: .6; }\n .pc-main-h .tb-chip.active {\n background: var(--heat-12);\n border-color: var(--heat-40);\n color: var(--heat);\n }\n .pc-main-h .tb-chip.active svg { opacity: .8; }\n\n /* search · 折叠图标态 + 展开输入框 */\n .pc-main-h .tb-search-wrap { display: flex; align-items: center; }\n .pc-main-h .tb-search-input {\n width: 0; height: 32px;\n padding: 0; margin: 0;\n border: 1px solid transparent;\n background: transparent;\n border-radius: var(--r-sm);\n font-size: 12.5px; color: var(--accent-black);\n font-family: inherit; outline: none;\n transition: width var(--t-base), padding var(--t-base), border-color var(--t-base), margin var(--t-base), background var(--t-base);\n }\n .pc-main-h .tb-search-wrap.expanded .tb-search-input {\n width: 220px;\n padding: 0 10px;\n margin-left: 6px;\n background: var(--surface);\n border-color: var(--border-faint);\n }\n .pc-main-h .tb-search-wrap.expanded .tb-search-input:focus { border-color: var(--heat-40); }\n .pc-main-h .tb-search-wrap.expanded .search-btn { border-color: var(--heat-40); color: var(--heat); }\n\n /* dropdown · chip 下拉菜单 */\n .pc-main-h .tb-menu-wrap { position: relative; }\n .pc-main-h .tb-menu {\n position: absolute;\n top: calc(100% + 6px); right: 0;\n min-width: 160px; max-width: 260px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n box-shadow: 0 8px 24px rgba(0,0,0,.08);\n padding: 4px;\n z-index: 50;\n display: none;\n max-height: 320px; overflow-y: auto;\n }\n .pc-main-h .tb-menu-wrap.open .tb-menu { display: block; }\n .pc-main-h .tb-menu-item {\n display: flex; align-items: center; gap: 8px;\n width: 100%; padding: 7px 10px;\n background: transparent; border: 0;\n border-radius: 4px;\n font-size: 12.5px; color: var(--black-alpha-72);\n font-family: inherit; cursor: pointer; text-align: left;\n transition: background var(--t-base), color var(--t-base);\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n }\n .pc-main-h .tb-menu-item:hover { background: var(--background-lighter); color: var(--accent-black); }\n .pc-main-h .tb-menu-item.active {\n background: var(--heat-12); color: var(--heat); font-weight: 500;\n }\n .pc-main-h .tb-menu-empty {\n padding: 10px;\n font-size: 11.5px; color: var(--black-alpha-48);\n font-family: var(--font-mono); letter-spacing: .02em;\n text-align: center;\n }\n\n /* 批次被筛选隐藏 */\n .pv-platform-section[data-hidden=\"1\"] { display: none !important; }\n .pc-main-body {\n flex: 1; min-height: 0;\n display: grid;\n grid-template-columns: 320px 1fr;\n }\n @media (max-width: 1280px) {\n .pc-main-body { grid-template-columns: 300px 1fr; }\n }\n .pc-main-body > .pc-form {\n border: 0; border-radius: 0;\n border-right: 1px solid var(--border-faint);\n background: var(--surface);\n }\n .pc-main-body > .pc-preview {\n border: 0; border-radius: 0;\n background: var(--background-base);\n }\n\n /* ─── 商品空间 (最左 · 单选 · 当前商品决定结果区批次) ─── */\n .pc-prod-space {\n background: var(--surface);\n border-right: 1px solid var(--border-faint);\n display: flex; flex-direction: column;\n min-height: 0;\n overflow: hidden;\n transition: opacity var(--t-base), transform var(--t-base);\n }\n .pc-layout.side-collapsed .pc-prod-space {\n opacity: 0;\n transform: translateX(-8px);\n pointer-events: none;\n }\n /* 顶部工具栏: 返回 + 折叠 (跟 image-optimize 视觉一致) */\n .pc-side-top {\n flex-shrink: 0;\n display: flex; align-items: center; gap: 8px;\n padding: 14px 14px 10px;\n border-bottom: 1px solid var(--border-faint);\n }\n .pc-side-top .back-pill {\n display: inline-flex; align-items: center; gap: 6px;\n height: 34px; padding: 0 13px 0 11px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-pill);\n color: var(--accent-black);\n font-size: 13px; font-weight: 500;\n font-family: inherit;\n cursor: pointer;\n transition: background var(--t-base), border-color var(--t-base), color var(--t-base);\n }\n .pc-side-top .back-pill:hover {\n background: var(--black-alpha-4);\n border-color: var(--black-alpha-24);\n color: var(--accent-black);\n }\n .pc-side-top .back-pill svg { width: 14px; height: 14px; }\n .pc-side-top .fold {\n margin-left: auto;\n width: 26px; height: 26px;\n background: transparent; border: 0; border-radius: var(--r-sm);\n display: grid; place-items: center;\n color: var(--black-alpha-48); cursor: pointer;\n transition: background var(--t-base), color var(--t-base);\n }\n .pc-side-top .fold:hover { background: var(--black-alpha-4); color: var(--accent-black); }\n .pc-side-top .fold svg { width: 14px; height: 14px; }\n .pc-ps-h {\n flex-shrink: 0;\n padding: 12px 14px 10px;\n }\n /* 商品列表 头部 (// 商品空间 + 新建商品 主 CTA · 右上显眼橙色) */\n .pc-list-h {\n flex-shrink: 0;\n display: flex; align-items: center; gap: 8px;\n padding: 4px 14px 10px;\n }\n .pc-list-h .mono {\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48); letter-spacing: .06em;\n text-transform: uppercase;\n }\n .pc-list-h .new-prod {\n margin-left: auto;\n height: 28px; padding: 0 12px 0 10px;\n display: inline-flex; align-items: center; gap: 6px;\n background: var(--heat); color: #fff;\n border: 1px solid var(--heat);\n border-radius: var(--r-sm);\n font-size: 12px; font-weight: 600;\n font-family: inherit;\n cursor: pointer;\n box-shadow:\n inset 0 -2px 4px rgba(250, 93, 25, 0.20),\n 0 1px 1px rgba(250, 93, 25, 0.12),\n 0 2px 4px rgba(250, 93, 25, 0.10);\n transition: filter var(--t-base), transform var(--t-fast), box-shadow var(--t-base);\n }\n .pc-list-h .new-prod:hover {\n filter: brightness(.96);\n box-shadow:\n inset 0 -2px 4px rgba(250, 93, 25, 0.20),\n 0 1px 1px rgba(250, 93, 25, 0.16),\n 0 4px 8px rgba(250, 93, 25, 0.20);\n }\n .pc-list-h .new-prod:active { transform: scale(.98); }\n .pc-list-h .new-prod svg { width: 12px; height: 12px; }\n .pc-ps-search {\n position: relative;\n height: 32px;\n }\n .pc-ps-search svg {\n position: absolute; left: 10px; top: 50%; transform: translateY(-50%);\n width: 13px; height: 13px;\n color: var(--black-alpha-48);\n z-index: 2;\n pointer-events: none;\n }\n .pc-ps-search input {\n width: 100%; height: 100%;\n padding: 0 10px 0 30px;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n font-size: 12.5px; color: var(--accent-black);\n font-family: inherit;\n outline: none;\n transition: border-color var(--t-base), background var(--t-base);\n }\n .pc-ps-search input:focus { border-color: var(--heat-40); background: var(--surface); }\n .pc-ps-search input::placeholder { color: var(--black-alpha-48); }\n .pc-ps-list {\n flex: 1; min-height: 0;\n overflow-y: auto;\n padding: 4px 10px 10px;\n display: flex; flex-direction: column; gap: 4px;\n }\n .pc-ps-empty {\n padding: 24px 14px;\n text-align: center;\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48);\n letter-spacing: .04em;\n line-height: 1.7;\n }\n .pc-prod-item {\n display: flex; align-items: center; gap: 10px;\n padding: 8px;\n border: 1px solid transparent;\n border-radius: var(--r-sm);\n cursor: pointer;\n transition: background var(--t-base), border-color var(--t-base);\n }\n .pc-prod-item:hover { background: var(--black-alpha-4); }\n .pc-prod-item.active { background: var(--heat-12); border-color: var(--heat-20); }\n .pc-prod-item .thumb {\n flex-shrink: 0;\n width: 36px; height: 36px;\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n overflow: hidden;\n }\n .pc-prod-item.active .thumb { border-color: var(--heat); }\n .pc-prod-item .body { flex: 1; min-width: 0; }\n .pc-prod-item .nm {\n font-size: 12.5px;\n color: var(--accent-black); font-weight: 500;\n line-height: 1.3;\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n }\n .pc-prod-item.active .nm { color: var(--heat); font-weight: 600; }\n .pc-prod-item .sub {\n margin-top: 2px;\n font-family: var(--font-mono); font-size: 10px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n }\n .pc-ps-all {\n flex-shrink: 0;\n margin: 0 10px 12px;\n padding: 9px 12px;\n background: var(--background-lighter);\n border: 1px dashed var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--black-alpha-72);\n font-size: 12px;\n font-family: inherit;\n cursor: pointer;\n display: flex; align-items: center; gap: 8px;\n transition: border-color var(--t-base), color var(--t-base), background var(--t-base);\n }\n .pc-ps-all:hover { border-color: var(--heat); color: var(--heat); background: var(--heat-12); }\n .pc-ps-all .ct {\n margin-left: auto;\n color: var(--black-alpha-48);\n font-family: var(--font-mono); font-size: 10.5px;\n }\n .pc-ps-all:hover .ct { color: var(--heat); }\n .pc-ps-all svg { width: 12px; height: 12px; }\n /* 左栏 表单 */\n .pc-form {\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n overflow-y: auto;\n padding: 18px 20px;\n display: flex; flex-direction: column;\n }\n .pc-step { margin-bottom: 22px; }\n .pc-step:last-child { margin-bottom: 0; }\n .pc-step-h { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }\n .pc-step-h .num {\n width: 22px; height: 22px; border-radius: 50%;\n background: var(--heat-12); color: var(--heat);\n font-family: var(--font-mono); font-size: 11px; font-weight: 700;\n display: grid; place-items: center;\n }\n .pc-step-h .title { font-size: 14px; font-weight: 600; color: var(--accent-black); }\n\n /* 商品选择 · 已选 chip 列表 */\n .prod-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 6px; }\n .prod-row {\n display: flex; align-items: center; gap: 10px;\n padding: 8px 10px;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n transition: background var(--t-base), border-color var(--t-base);\n }\n .prod-row .thumb { width: 28px; height: 28px; border-radius: var(--r-sm); flex-shrink: 0; }\n .prod-row .info { flex: 1; min-width: 0; }\n .prod-row .nm {\n font-size: 12.5px; color: var(--accent-black); font-weight: 500;\n overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n }\n .prod-row .meta {\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n margin-top: 2px;\n }\n .prod-row .x {\n width: 22px; height: 22px;\n display: grid; place-items: center;\n background: transparent; border: 0;\n border-radius: var(--r-sm);\n cursor: pointer;\n color: var(--black-alpha-48);\n flex-shrink: 0;\n transition: background var(--t-base), color var(--t-base);\n }\n .prod-row .x:hover { background: var(--black-alpha-8); color: var(--accent-crimson); }\n .prod-row .x svg { width: 12px; height: 12px; }\n .prod-row .swap {\n width: 22px; height: 22px;\n display: grid; place-items: center;\n background: transparent; border: 0;\n border-radius: var(--r-sm);\n cursor: pointer;\n color: var(--black-alpha-48);\n flex-shrink: 0;\n transition: background var(--t-base), color var(--t-base);\n }\n .prod-row .swap:hover { background: var(--heat-12); color: var(--heat); }\n .prod-row .swap svg { width: 13px; height: 13px; }\n .prod-empty {\n padding: 14px 10px;\n text-align: center;\n background: var(--background-lighter);\n border: 1px dashed var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--black-alpha-48);\n font-size: 12px;\n margin-bottom: 6px;\n }\n .prod-empty .mono {\n font-family: var(--font-mono);\n font-size: 10.5px;\n letter-spacing: .02em;\n margin-top: 4px;\n }\n .prod-add {\n display: flex; align-items: center; justify-content: center; gap: 6px;\n padding: 8px 10px;\n background: transparent;\n border: 1px dashed var(--heat-40);\n border-radius: var(--r-sm);\n cursor: pointer;\n font-size: 12.5px; color: var(--heat);\n font-family: inherit;\n transition: background var(--t-base);\n width: 100%;\n }\n .prod-add:hover { background: var(--heat-12); }\n .prod-add svg { width: 12px; height: 12px; }\n .prod-add[hidden] { display: none; }\n\n /* 商品库全屏弹窗 */\n .pl-modal-bg {\n position: fixed; inset: 0;\n background: var(--surface);\n z-index: 998;\n display: none;\n }\n .pl-modal-bg.show { display: flex; }\n .pl-modal {\n margin: 0; flex: 1;\n background: var(--surface);\n overflow: hidden;\n display: flex; flex-direction: column;\n }\n .pl-modal-h {\n display: flex; align-items: center; gap: 14px;\n padding: 14px 28px;\n border-bottom: 1px solid var(--border-faint);\n flex-shrink: 0;\n }\n .pl-modal-h h2 { font-size: 16px; font-weight: 600; }\n .pl-modal-h .ct {\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n }\n .pl-modal-h .actions { margin-left: auto; display: flex; gap: 10px; }\n .pl-modal-body { flex: 1; min-height: 0; display: grid; grid-template-columns: 200px 1fr; }\n .pl-side {\n border-right: 1px solid var(--border-faint);\n padding: 18px 0;\n overflow-y: auto;\n }\n .pl-side .pl-side-h {\n padding: 0 20px 8px;\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48); letter-spacing: .06em;\n }\n .pl-side .pl-side-item {\n display: flex; align-items: center; gap: 8px;\n padding: 9px 20px;\n cursor: pointer;\n color: var(--black-alpha-72);\n font-size: 13px;\n border-left: 3px solid transparent;\n transition: background var(--t-base), color var(--t-base);\n }\n .pl-side .pl-side-item:hover { background: var(--black-alpha-4); }\n .pl-side .pl-side-item.active {\n background: var(--heat-12); color: var(--accent-black);\n border-left-color: var(--heat); font-weight: 600;\n }\n .pl-side .pl-side-item .ct {\n margin-left: auto;\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-48);\n }\n .pl-main { overflow-y: auto; padding: 0; display: flex; flex-direction: column; }\n .pl-toolbar {\n padding: 14px 28px;\n border-bottom: 1px solid var(--border-faint);\n display: flex; align-items: center; gap: 12px;\n flex-shrink: 0;\n }\n .pl-toolbar .search { position: relative; flex: 1; max-width: 360px; }\n .pl-toolbar .search input {\n width: 100%; height: 32px;\n padding: 0 10px 0 32px;\n background: var(--background-lighter);\n border: 1px solid var(--black-alpha-12);\n border-radius: var(--r-sm);\n font-size: 12.5px; font-family: inherit;\n color: var(--accent-black); outline: none;\n }\n .pl-toolbar .search svg {\n position: absolute; left: 10px; top: 50%; transform: translateY(-50%);\n width: 14px; height: 14px; color: var(--black-alpha-48);\n }\n .pl-toolbar .btn-new {\n height: 32px; padding: 0 14px;\n display: inline-flex; align-items: center; gap: 6px;\n background: var(--surface);\n border: 1px solid var(--black-alpha-12);\n border-radius: var(--r-sm);\n color: var(--accent-black);\n font-family: inherit; font-size: 12.5px;\n cursor: pointer;\n margin-left: auto;\n transition: background var(--t-base), border-color var(--t-base);\n }\n .pl-toolbar .btn-new:hover { background: var(--background-lighter); border-color: var(--black-alpha-24); }\n .pl-toolbar .btn-new svg { width: 13px; height: 13px; }\n .pl-scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 20px 28px 28px; }\n .pl-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));\n gap: 12px;\n }\n .pl-card {\n position: relative;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n padding: 10px;\n cursor: pointer;\n display: flex; flex-direction: column; gap: 6px;\n transition: background var(--t-base), border-color var(--t-base);\n }\n .pl-card:hover { background: var(--surface); }\n .pl-card.selected { border-color: var(--heat); background: var(--heat-12); }\n .pl-card .pl-thumb { aspect-ratio: 1; border-radius: var(--r-sm); }\n .pl-card .pl-name { font-size: 12.5px; font-weight: 500; color: var(--accent-black); }\n .pl-card .pl-meta {\n font-family: var(--font-mono); font-size: 10.5px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n }\n .pl-card .pl-check {\n position: absolute; top: 16px; right: 16px;\n width: 22px; height: 22px;\n background: rgba(255,255,255,.95);\n border: 1.5px solid var(--black-alpha-24);\n border-radius: 50%;\n display: grid; place-items: center;\n z-index: 2;\n color: var(--accent-white);\n }\n .pl-card .pl-check svg { width: 11px; height: 11px; opacity: 0; }\n .pl-card.selected .pl-check { background: var(--heat); border-color: var(--heat); }\n .pl-card.selected .pl-check svg { opacity: 1; }\n /* 卡片右上 actions: 编辑 + 删除 (hover 显示, check 左侧) */\n .pl-card .pl-card-actions {\n position: absolute;\n top: 14px; right: 44px;\n display: flex; gap: 4px;\n z-index: 2;\n opacity: 0;\n transition: opacity var(--t-base);\n }\n .pl-card:hover .pl-card-actions { opacity: 1; }\n .pl-card .pl-act {\n width: 26px; height: 26px;\n background: rgba(255,255,255,.95);\n border: 1px solid var(--black-alpha-12);\n border-radius: var(--r-sm);\n display: grid; place-items: center;\n cursor: pointer;\n color: var(--black-alpha-72);\n transition: color var(--t-base), border-color var(--t-base), background var(--t-base);\n }\n .pl-card .pl-act:hover { color: var(--heat); border-color: var(--heat); background: var(--surface); }\n .pl-card .pl-act.danger:hover { color: var(--accent-crimson); border-color: var(--accent-crimson); }\n .pl-card .pl-act svg { width: 12px; height: 12px; }\n\n /* 编辑商品 drawer */\n .pc-drawer { width: 720px; max-width: 100vw; z-index: 1101; }\n .pc-drawer .drawer-b { padding: 24px 28px; }\n #pc-drawer-bg.drawer-bg { z-index: 1100; }\n .pc-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; }\n .pc-field-label { font-size: 13px; font-weight: 500; color: var(--accent-black); }\n .pc-field-label .req { color: var(--accent-crimson); margin-left: 2px; }\n .pc-field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 18px; }\n .pc-field-row > div { display: flex; flex-direction: column; gap: 6px; }\n .pc-bullets {\n list-style: none; padding: 0; margin: 0;\n display: flex; flex-direction: column; gap: 6px;\n }\n .pc-bullets li {\n display: flex; align-items: center; gap: 8px;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n padding: 0 10px;\n height: 36px;\n }\n .pc-bullets li.add { border-style: dashed; border-color: var(--heat-40); }\n .pc-bullets li .num {\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-48); width: 18px; text-align: center;\n flex-shrink: 0;\n }\n .pc-bullets li.add .num { color: var(--heat); }\n .pc-bullets li input {\n flex: 1; border: 0; background: transparent; outline: none;\n font-size: 13px; color: var(--accent-black);\n font-family: inherit;\n }\n .pc-bullets li input::placeholder { color: var(--black-alpha-48); }\n .pc-bullets li .rm {\n width: 22px; height: 22px;\n background: transparent; border: 0; border-radius: var(--r-sm);\n color: var(--black-alpha-48); cursor: pointer;\n display: grid; place-items: center;\n }\n .pc-bullets li .rm:hover { color: var(--accent-crimson); background: var(--black-alpha-4); }\n .pc-bullets li .rm svg { width: 11px; height: 11px; }\n /* 商品图片 grid (对齐 product-detail .ov-images-grid) */\n .pc-imgs {\n display: grid;\n grid-template-columns: repeat(6, 1fr);\n gap: 8px;\n }\n .pc-imgs .thumb {\n aspect-ratio: 1 / 1;\n border-radius: var(--r-sm);\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n position: relative;\n overflow: hidden;\n }\n .pc-imgs .thumb .ph-frame {\n position: absolute; inset: 0;\n display: grid; place-items: center;\n font-family: var(--font-mono); font-size: 10px;\n color: var(--black-alpha-32);\n background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);\n }\n .pc-imgs .thumb .rm {\n position: absolute; top: 4px; right: 4px;\n width: 18px; height: 18px;\n background: rgba(0,0,0,.5);\n color: #fff; border: 0;\n border-radius: var(--r-sm);\n display: grid; place-items: center;\n cursor: pointer;\n opacity: 0;\n transition: opacity var(--t-base);\n }\n .pc-imgs .thumb:hover .rm { opacity: 1; }\n .pc-imgs .thumb .rm svg { width: 10px; height: 10px; }\n .pc-imgs .img-upload {\n aspect-ratio: 1 / 1;\n border-radius: var(--r-sm);\n background: var(--heat-12);\n border: 1.5px dashed var(--heat-40);\n display: grid; place-items: center;\n color: var(--heat);\n cursor: pointer;\n transition: background var(--t-base), border-color var(--t-base);\n }\n .pc-imgs .img-upload:hover { background: var(--heat-20); border-color: var(--heat); }\n .pc-imgs .img-upload svg { width: 18px; height: 18px; }\n .pl-modal-f {\n padding: 14px 28px;\n border-top: 1px solid var(--border-faint);\n display: flex; justify-content: flex-end; align-items: center; gap: 10px;\n flex-shrink: 0;\n }\n .pl-modal-f .summary {\n margin-right: auto;\n font-family: var(--font-mono); font-size: 12px;\n color: var(--black-alpha-56); letter-spacing: .02em;\n }\n .pl-modal-f .summary b { color: var(--heat); font-weight: 700; }\n\n /* 平台多选卡片 */\n .platform-grid {\n display: grid;\n grid-template-columns: repeat(3, 1fr);\n gap: 6px;\n }\n .platform-card {\n position: relative;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n padding: 10px 6px;\n cursor: pointer;\n text-align: center;\n transition: background var(--t-base), border-color var(--t-base);\n }\n .platform-card:hover { background: var(--surface); }\n .platform-card.selected { border-color: var(--heat); background: var(--heat-12); }\n .platform-card .p-logo {\n width: 32px; height: 32px;\n margin: 0 auto 4px;\n border-radius: var(--r-md);\n display: grid; place-items: center;\n color: var(--accent-white);\n font-family: var(--font-mono);\n font-size: 11px; font-weight: 700;\n }\n .platform-card .p-name {\n font-size: 11.5px;\n color: var(--accent-black);\n font-weight: 500;\n }\n .platform-card.selected .p-name { color: var(--heat); }\n .platform-card .p-check {\n position: absolute; top: 4px; right: 4px;\n width: 16px; height: 16px;\n border-radius: 50%;\n background: transparent;\n border: 1.5px solid var(--black-alpha-24);\n }\n .platform-card.selected .p-check {\n background: var(--heat); border-color: var(--heat);\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23ffffff' stroke-width='2.5'%3E%3Cpolyline points='3 8 7 12 13 4'/%3E%3C/svg%3E\");\n background-position: center;\n background-size: 10px 10px;\n background-repeat: no-repeat;\n border: 0;\n }\n /* 平台 logo 配色 */\n .p-logo.dy { background: #000; }\n .p-logo.tb { background: #ff6f00; }\n .p-logo.tm { background: #ff0036; }\n .p-logo.jd { background: #e1251b; }\n .p-logo.pdd { background: #e02e24; }\n .p-logo.xhs { background: #ff2741; }\n .p-logo.ks { background: #ff4906; }\n .p-logo.sph { background: #07c160; }\n .p-logo.amz { background: #ff9900; }\n .p-logo.al { background: #2c4af1; }\n\n /* pill row */\n .pill-row { display: flex; gap: 6px; }\n .pill-row .opt {\n flex: 1; height: 32px;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--black-alpha-72); font-size: 12.5px;\n cursor: pointer; font-family: inherit;\n transition: background var(--t-base), color var(--t-base), border-color var(--t-base);\n }\n .pill-row .opt:hover { color: var(--accent-black); }\n .pill-row .opt.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat-40); font-weight: 600; }\n .pc-sub-h { font-size: 12px; color: var(--black-alpha-48); margin-bottom: 6px; font-family: var(--font-mono); letter-spacing: .02em; }\n .pc-sub { margin-bottom: 12px; }\n .pc-sub:last-child { margin-bottom: 0; }\n\n /* CTA */\n .pc-cta { margin-top: auto; padding-top: 14px; }\n .pc-cta .btn-gen {\n width: 100%; justify-content: center;\n padding: 12px; font-size: 14px; font-weight: 600;\n }\n .pc-cta .btn-gen.disabled { opacity: .45; cursor: not-allowed; pointer-events: none; }\n .pc-cta-hint {\n margin-top: 8px;\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n text-align: center; line-height: 1.5;\n }\n\n /* 右栏 预览 */\n .pc-preview {\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n padding: 18px 22px;\n display: flex; flex-direction: column;\n overflow-y: auto;\n }\n /* prompt-style summary 卡片 (引号 icon + 灰底 + 右上 meta) */\n .pc-pv-h {\n position: relative;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n padding: 14px 18px 14px 44px;\n margin-bottom: 14px;\n }\n .pc-pv-h .quote-icon {\n position: absolute;\n top: 13px; left: 16px;\n width: 18px; height: 18px;\n color: var(--black-alpha-24);\n }\n .pc-pv-h .pv-meta {\n float: right;\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--black-alpha-48);\n letter-spacing: .04em;\n line-height: 1.5;\n }\n .pc-pv-h .pv-meta b { color: var(--accent-black); font-weight: 600; }\n .pc-pv-h .pv-line {\n font-size: 13px;\n color: var(--accent-black);\n line-height: 1.6;\n display: flex; align-items: center;\n }\n .pc-pv-h .pv-line .k {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--black-alpha-48);\n letter-spacing: .04em;\n margin-right: 8px;\n min-width: 36px;\n }\n .pc-pv-h .pv-line .v { font-weight: 500; }\n .pc-pv-h .pv-line .swap {\n margin-left: 10px;\n font-size: 11.5px; color: var(--heat);\n cursor: pointer;\n }\n .pc-pv-h .pv-line .swap:hover { text-decoration: underline; }\n\n .pv-platform-section { margin-bottom: 24px; }\n .pv-platform-section:last-child { margin-bottom: 0; }\n .pv-platform-section .ps-h {\n display: flex; align-items: center; gap: 8px;\n margin-bottom: 10px;\n font-size: 13px; font-weight: 600; color: var(--accent-black);\n }\n .pv-platform-section .ps-h .ct {\n margin-left: auto; font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-48); letter-spacing: .02em; font-weight: 400;\n }\n .pv-platform-section .ps-h .batch-lab {\n font-family: var(--font-mono); font-size: 10.5px; font-weight: 600;\n padding: 2px 8px; border-radius: var(--r-pill); letter-spacing: .04em;\n }\n .pv-platform-section .ps-h .batch-lab.gen { background: var(--background-lighter); color: var(--black-alpha-56); }\n .pv-platform-section .ps-h .batch-lab.rerun { background: var(--heat-12); color: var(--heat); }\n .pv-platform-section .ps-h .sep { color: var(--black-alpha-32); font-weight: 400; }\n .pv-platform-section .ps-grid {\n display: grid;\n grid-template-columns: repeat(4, 1fr);\n gap: 12px;\n }\n @media (max-width: 1400px) {\n .pv-platform-section .ps-grid { gap: 10px; }\n }\n /* 结果卡片 · 与图片创作 .io-cell 视觉一致 */\n .pv-platform-section .ps-grid .mp-result {\n position: relative;\n aspect-ratio: 1;\n border-radius: var(--r-md);\n overflow: hidden;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n cursor: pointer;\n transition: border-color var(--t-base);\n }\n .pv-platform-section .ps-grid .mp-result:hover { border-color: var(--black-alpha-32); }\n .pv-platform-section .ps-grid .mp-result .mp-r-thumb { position: absolute; inset: 0; }\n .pv-platform-section .ps-grid .mp-result .mp-r-thumb .ph-frame {\n position: absolute; inset: 0;\n display: grid; place-items: center;\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-32); letter-spacing: .02em;\n background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);\n }\n .pv-platform-section .ps-grid .mp-result.gen .mp-r-thumb .ph-frame { animation: pc-pulse 1.4s ease-in-out infinite; }\n @keyframes pc-pulse {\n 0%, 100% { opacity: 1; }\n 50% { opacity: .55; }\n }\n .pv-platform-section .ps-grid .mp-result.err { border-color: var(--accent-crimson, #c43d3d); }\n .pv-platform-section .ps-grid .mp-result.err .mp-r-thumb .ph-frame {\n color: var(--accent-crimson, #c43d3d);\n background: rgba(196, 61, 61, .05);\n }\n /* 右上 hover 操作组 · 同 spec §4.18 .gen-image-actions */\n .pv-platform-section .ps-grid .mp-result .cell-ops {\n position: absolute; top: 8px; right: 8px;\n display: flex; gap: 2px;\n padding: 2px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n box-shadow: 0 2px 8px rgba(0,0,0,.08);\n opacity: 0;\n transition: opacity var(--t-base);\n z-index: 2;\n }\n .pv-platform-section .ps-grid .mp-result:hover .cell-ops { opacity: 1; }\n .pv-platform-section .ps-grid .mp-result .cell-ops button {\n width: 28px; height: 28px;\n background: transparent;\n border: 0;\n border-radius: 6px;\n color: var(--black-alpha-56);\n cursor: pointer;\n display: grid; place-items: center;\n transition: background var(--t-base), color var(--t-base);\n }\n .pv-platform-section .ps-grid .mp-result .cell-ops button:hover {\n background: var(--black-alpha-4);\n color: var(--accent-black);\n }\n .pv-platform-section .ps-grid .mp-result .cell-ops button svg { width: 14px; height: 14px; }\n .pv-platform-section .ps-grid .mp-result.adopted .cell-ops .r-check { display: none; }\n /* 中央反馈 toast · 同 spec §4.18 .gen-image-feedback */\n .pv-platform-section .ps-grid .mp-result .cell-feedback {\n position: absolute; inset: 0;\n display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px;\n background: rgba(38, 38, 38, .88);\n color: var(--accent-white);\n border-radius: var(--r-md);\n opacity: 0;\n pointer-events: none;\n transition: opacity .2s var(--t-base, ease);\n z-index: 3;\n }\n .pv-platform-section .ps-grid .mp-result.show-feedback .cell-feedback { opacity: 1; }\n .pv-platform-section .ps-grid .mp-result .cell-feedback svg { width: 20px; height: 20px; }\n .pv-platform-section .ps-grid .mp-result .cell-feedback span { font-size: 12.5px; font-weight: 500; letter-spacing: .02em; }\n .pv-platform-section .ps-grid .mp-result .cell-more-wrap { position: relative; }\n .pv-platform-section .ps-grid .mp-result .cell-more-menu {\n position: absolute; top: calc(100% + 4px); right: 0;\n min-width: 132px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n box-shadow: 0 6px 24px rgba(0,0,0,.10);\n padding: 4px;\n display: none;\n z-index: 12;\n }\n .pv-platform-section .ps-grid .mp-result .cell-more-wrap.open .cell-more-menu { display: block; }\n .pv-platform-section .ps-grid .mp-result .cell-more-menu button {\n width: 100%;\n display: inline-flex !important; align-items: center; gap: 8px;\n padding: 7px 10px !important;\n background: transparent !important;\n border: 0 !important; border-radius: var(--r-sm) !important;\n font-size: 12.5px;\n color: var(--accent-black) !important;\n font-family: inherit;\n text-align: left;\n cursor: pointer;\n backdrop-filter: none !important;\n height: auto !important;\n justify-content: flex-start !important;\n }\n .pv-platform-section .ps-grid .mp-result .cell-more-menu button:hover {\n background: var(--background-lighter) !important;\n color: var(--heat) !important;\n }\n .pv-platform-section .ps-grid .mp-result .cell-more-menu button.danger:hover {\n color: var(--accent-crimson) !important;\n background: var(--crimson-bg, #fdebea) !important;\n }\n .pv-platform-section .ps-grid .mp-result .cell-more-menu button svg { width: 13px !important; height: 13px !important; }\n /* 已采用角标 */\n .pv-platform-section .ps-grid .mp-result .adopt-badge {\n position: absolute; top: 6px; left: 6px;\n background: var(--accent-forest, #42c366);\n color: #fff;\n font-family: var(--font-mono); font-size: 10px; font-weight: 600;\n letter-spacing: .04em;\n padding: 1px 6px;\n border-radius: var(--r-pill);\n z-index: 3;\n display: none;\n }\n .pv-platform-section .ps-grid .mp-result.adopted .adopt-badge { display: inline-block; }\n\n /* 每批次下方的批量操作 (胶囊按钮组 · 左对齐) */\n .pc-pv-batch {\n margin-top: 10px;\n display: flex; gap: 10px; align-items: center; justify-content: flex-start;\n }\n .pc-pv-batch[hidden] { display: none; }\n .pv-platform-section .pc-pv-batch.batch-foot { margin-top: 10px; }\n\n /* 预览区空态 (新任务且未生成) */\n .pc-pv-empty-state {\n flex: 1;\n display: flex; flex-direction: column;\n align-items: center; justify-content: center;\n text-align: center;\n padding: 40px 24px;\n gap: 6px;\n }\n .pc-pv-empty-state[hidden] { display: none; }\n .pc-pv-empty-state .mono {\n font-family: var(--font-mono);\n font-size: 10.5px;\n color: var(--black-alpha-48);\n letter-spacing: .06em;\n margin-bottom: 4px;\n }\n .pc-pv-empty-state .title {\n font-size: 14px;\n font-weight: 600;\n color: var(--accent-black);\n }\n .pc-pv-empty-state .hint {\n font-size: 12.5px;\n color: var(--black-alpha-48);\n line-height: 1.6;\n max-width: 320px;\n }\n .pc-pv-empty-state .hint b { color: var(--heat); font-weight: 600; }\n /* 预览区 hidden 时收起所有内容元素 */\n #pv-summary[hidden], #pv-results[hidden], #pv-foot[hidden] { display: none; }\n .pc-pv-batch .summary {\n margin-right: auto;\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n }\n .pc-pv-batch .summary b { color: var(--heat); font-weight: 700; }\n /* 底栏按钮 · 同 spec §4.18 + image-optimize .io-msg-ops button */\n .pc-pv-batch .pill-btn {\n height: 30px;\n padding: 0 12px;\n display: inline-flex; align-items: center; gap: 6px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n color: var(--accent-black);\n font-family: inherit; font-size: 12.5px;\n cursor: pointer;\n transition: background var(--t-base), border-color var(--t-base), color var(--t-base);\n }\n .pc-pv-batch .pill-btn:hover {\n border-color: var(--heat-20); color: var(--heat); background: var(--heat-12);\n }\n .pc-pv-batch .pill-btn svg { width: 13px; height: 13px; }\n .pc-pv-batch .pill-btn.icon { width: 30px; padding: 0; justify-content: center; }\n /* 批次更多气泡 */\n .pc-pv-batch .batch-more-wrap { position: relative; display: inline-flex; }\n .pc-pv-batch .batch-more-menu {\n position: absolute; bottom: calc(100% + 6px); right: 0;\n min-width: 168px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n box-shadow: 0 6px 24px rgba(0,0,0,.10);\n padding: 4px;\n display: none;\n z-index: 12;\n }\n .pc-pv-batch .batch-more-wrap.open .batch-more-menu { display: block; }\n .pc-pv-batch .batch-more-menu button {\n width: 100%;\n display: inline-flex !important; align-items: center; gap: 8px;\n height: auto !important;\n padding: 7px 10px !important;\n background: transparent !important;\n border: 0 !important; border-radius: var(--r-sm) !important;\n font-size: 12.5px;\n color: var(--accent-black) !important;\n text-align: left;\n justify-content: flex-start !important;\n cursor: pointer; font-family: inherit;\n }\n .pc-pv-batch .batch-more-menu button:hover {\n background: var(--background-lighter) !important;\n color: var(--heat) !important;\n }\n .pc-pv-batch .batch-more-menu button.danger:hover {\n color: var(--accent-crimson) !important;\n background: var(--crimson-bg, #fdebea) !important;\n }\n .pc-pv-batch .batch-more-menu button svg { width: 13px !important; height: 13px !important; flex-shrink: 0; }\n\n .pc-pv-foot {\n margin-top: 14px; padding-top: 12px;\n border-top: 1px solid var(--border-faint);\n font-family: var(--font-mono); font-size: 11.5px;\n color: var(--black-alpha-56); letter-spacing: .02em;\n line-height: 1.6;\n }\n .pc-pv-foot a { color: var(--heat); }\n\n /* 空状态 */\n .pc-pv-empty {\n flex: 1;\n display: grid; place-items: center;\n color: var(--black-alpha-48);\n font-size: 13px;\n text-align: center;\n }\n .pc-pv-empty .mono {\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-48); margin-top: 6px;\n letter-spacing: .04em;\n }\n\n @media (max-width: 1100px) {\n .pc-layout { grid-template-columns: 1fr; }\n }\n</style>\n</head>\n<body>\n<div id=\"page\">\n\n <div class=\"pc-layout\">\n\n <!-- ===== 最左栏 · 商品空间 (单选 · 当前商品决定结果区批次) ===== -->\n <aside class=\"pc-prod-space\" id=\"prod-space\">\n <!-- 顶部 · 返回 + 折叠 (跟图片创作风格一致) -->\n <div class=\"pc-side-top\">\n <button class=\"back-pill\" type=\"button\" onclick=\"history.length > 1 ? history.back() : location.href='asset-factory.html'\" title=\"返回\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M15 18l-6-6 6-6\"/></svg>\n <span>返回</span>\n </button>\n <button class=\"fold\" type=\"button\" title=\"折叠侧栏\" style=\"margin-left:auto\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/><path d=\"M9 3v18\"/></svg>\n </button>\n </div>\n <div class=\"pc-ps-h\">\n <div class=\"pc-ps-search\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/></svg>\n <input type=\"text\" id=\"ps-search-input\" placeholder=\"搜索商品 / 分类\">\n </div>\n </div>\n <!-- 商品列表 标题行 · 右上显眼新建按钮 -->\n <div class=\"pc-list-h\">\n <span class=\"mono\">// 商品空间</span>\n <button class=\"new-prod\" type=\"button\" id=\"ps-new-btn\" title=\"新建商品\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 5v14M5 12h14\"/></svg>\n <span>新建商品</span>\n </button>\n </div>\n <div class=\"pc-ps-list\" id=\"ps-list\"></div>\n <button class=\"pc-ps-all\" type=\"button\" id=\"ps-all-btn\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"2\"/><path d=\"M3 9h18M9 4v16\"/></svg>\n <span>全部商品</span>\n <span class=\"ct\" id=\"ps-all-ct\">0 个</span>\n <svg style=\"margin-left:0\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12h14M12 5l7 7-7 7\"/></svg>\n </button>\n </aside>\n\n <!-- ===== 主区 · 头部 + 参数/结果 双栏 ===== -->\n <section class=\"pc-main\">\n\n <!-- 主区顶部 · toolbar (商品标题 + 搜索 + 筛选 · 跟图片创作一致) -->\n <div class=\"pc-main-h\">\n <button class=\"side-restore-btn\" type=\"button\" id=\"pc-side-restore\" hidden title=\"展开商品空间\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/><path d=\"M9 3v18\"/></svg>\n 商品\n </button>\n <div class=\"cur-title\">\n <span class=\"crumb\">// 商品空间</span>\n <span class=\"nm placeholder\" id=\"cur-prod-nm\">未选择 · 请在左侧商品空间选一个</span>\n </div>\n <span class=\"spacer\"></span>\n <div class=\"tb-search-wrap\" id=\"pc-search-wrap\">\n <button class=\"search-btn\" type=\"button\" title=\"搜索批次/平台\" id=\"pc-search-toggle\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/></svg>\n </button>\n <input type=\"text\" class=\"tb-search-input\" id=\"pc-search-input\" placeholder=\"搜索批次/平台…\" autocomplete=\"off\">\n </div>\n <div class=\"tb-menu-wrap\" data-filter=\"time\">\n <button class=\"tb-chip\" type=\"button\" id=\"pc-chip-time\">\n <span class=\"lbl\">时间</span>\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg>\n </button>\n <div class=\"tb-menu\" id=\"pc-menu-time\" role=\"listbox\" aria-labelledby=\"pc-chip-time\">\n <button class=\"tb-menu-item active\" type=\"button\" data-val=\"all\">全部时间</button>\n <button class=\"tb-menu-item\" type=\"button\" data-val=\"today\">今天</button>\n <button class=\"tb-menu-item\" type=\"button\" data-val=\"1h\">1 小时内</button>\n <button class=\"tb-menu-item\" type=\"button\" data-val=\"10min\">10 分钟内</button>\n </div>\n </div>\n <div class=\"tb-menu-wrap\" data-filter=\"platform\">\n <button class=\"tb-chip\" type=\"button\" id=\"pc-chip-platform\">\n <span class=\"lbl\">平台</span>\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg>\n </button>\n <div class=\"tb-menu\" id=\"pc-menu-platform\" role=\"listbox\" aria-labelledby=\"pc-chip-platform\">\n <button class=\"tb-menu-item active\" type=\"button\" data-val=\"all\">全部平台</button>\n <div class=\"tb-menu-empty\">暂无批次,生成后可按平台筛选</div>\n </div>\n </div>\n </div>\n\n <div class=\"pc-main-body\">\n\n <!-- 左 · 参数 -->\n <div class=\"pc-form\">\n\n <!-- ① 选择平台 (单选, 10 个热门电商平台) -->\n <div class=\"pc-step\">\n <div class=\"pc-step-h\">\n <span class=\"num\">1</span>\n <span class=\"title\">选择平台</span>\n </div>\n <div class=\"platform-grid\" id=\"platform-grid\">\n <div class=\"platform-card\" data-id=\"dy\" data-name=\"抖音电商\">\n <div class=\"p-check\"></div>\n <div class=\"p-logo dy\">抖</div>\n <div class=\"p-name\">抖音电商</div>\n </div>\n <div class=\"platform-card\" data-id=\"tb\" data-name=\"淘宝\">\n <div class=\"p-check\"></div>\n <div class=\"p-logo tb\">淘</div>\n <div class=\"p-name\">淘宝</div>\n </div>\n <div class=\"platform-card\" data-id=\"tm\" data-name=\"天猫\">\n <div class=\"p-check\"></div>\n <div class=\"p-logo tm\">猫</div>\n <div class=\"p-name\">天猫</div>\n </div>\n <div class=\"platform-card\" data-id=\"jd\" data-name=\"京东\">\n <div class=\"p-check\"></div>\n <div class=\"p-logo jd\">京</div>\n <div class=\"p-name\">京东</div>\n </div>\n <div class=\"platform-card\" data-id=\"pdd\" data-name=\"拼多多\">\n <div class=\"p-check\"></div>\n <div class=\"p-logo pdd\">拼</div>\n <div class=\"p-name\">拼多多</div>\n </div>\n <div class=\"platform-card\" data-id=\"xhs\" data-name=\"小红书\">\n <div class=\"p-check\"></div>\n <div class=\"p-logo xhs\">红</div>\n <div class=\"p-name\">小红书</div>\n </div>\n <div class=\"platform-card\" data-id=\"ks\" data-name=\"快手\">\n <div class=\"p-check\"></div>\n <div class=\"p-logo ks\">快</div>\n <div class=\"p-name\">快手</div>\n </div>\n <div class=\"platform-card\" data-id=\"sph\" data-name=\"视频号\">\n <div class=\"p-check\"></div>\n <div class=\"p-logo sph\">视</div>\n <div class=\"p-name\">视频号</div>\n </div>\n <div class=\"platform-card\" data-id=\"amz\" data-name=\"亚马逊\">\n <div class=\"p-check\"></div>\n <div class=\"p-logo amz\">a</div>\n <div class=\"p-name\">亚马逊</div>\n </div>\n <div class=\"platform-card\" data-id=\"al\" data-name=\"1688\">\n <div class=\"p-check\"></div>\n <div class=\"p-logo al\">阿</div>\n <div class=\"p-name\">1688</div>\n </div>\n </div>\n </div>\n\n <!-- ② 生成设置 -->\n <div class=\"pc-step\">\n <div class=\"pc-step-h\">\n <span class=\"num\">2</span>\n <span class=\"title\">生成设置</span>\n </div>\n <div class=\"pc-sub\">\n <div class=\"pc-sub-h\">// 生成数量</div>\n <div class=\"pill-row\" data-key=\"count\">\n <button type=\"button\" class=\"opt active\" data-val=\"4\">4 张</button>\n <button type=\"button\" class=\"opt\" data-val=\"8\">8 张</button>\n <button type=\"button\" class=\"opt\" data-val=\"12\">12 张</button>\n </div>\n </div>\n </div>\n\n <!-- 底部 立即生成 -->\n <div class=\"pc-cta\">\n <button class=\"btn btn-primary btn-gen\" id=\"pc-go-btn\" type=\"button\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 3l14 9-14 9V3z\"/></svg>\n 立即生成 (预估 <span id=\"cost-total\">¥4.00</span>)\n </button>\n <div class=\"pc-cta-hint\">// 满意后点 [入资产库] 才扣费 · 失败不扣</div>\n </div>\n\n </div>\n\n <!-- ===== 右栏 · 预览 ===== -->\n <div class=\"pc-preview\" id=\"pc-preview\">\n <!-- 空态(新任务态 & 还没立即生成时显示) -->\n <div class=\"pc-pv-empty-state\" id=\"pv-empty\">\n <div class=\"mono\">// EMPTY STATE</div>\n <div class=\"title\">还没有生成结果</div>\n <div class=\"hint\">先选商品、选平台,点击 <b>立即生成</b> 后,效果图会出现在这里</div>\n </div>\n\n <div class=\"pc-pv-h\" id=\"pv-summary\">\n <svg class=\"quote-icon\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><path d=\"M9 7c-2.76 0-5 2.24-5 5 0 1.84 1 3.45 2.48 4.32C5.99 17.36 4.99 18 4 18v2c3.31 0 6-2.69 6-6V8c0-.55-.45-1-1-1zm9 0c-2.76 0-5 2.24-5 5 0 1.84 1 3.45 2.48 4.32-.49 1.04-1.49 1.68-2.48 1.68v2c3.31 0 6-2.69 6-6V8c0-.55-.45-1-1-1z\"/></svg>\n <div class=\"pv-meta\"><b id=\"pv-count\">4 张</b></div>\n <div class=\"pv-line\"><span class=\"k\">商品</span><span class=\"v\" id=\"pv-prod\">未选择</span></div>\n <div class=\"pv-line\"><span class=\"k\">平台</span><span class=\"v\" id=\"pv-platforms\">未选择</span></div>\n </div>\n <div id=\"pv-results\">\n <!-- 默认占位 -->\n </div>\n\n <div class=\"pc-pv-foot\" id=\"pv-foot\">\n // 采用即扣费并入对应商品的 <a href=\"products.html\">AI 素材库 →</a>;未采用的图不扣费、不保存\n <br>// 切换左侧商品空间 · 查看其他商品的批次记录\n </div>\n </div>\n\n </div><!-- /.pc-main-body -->\n </section><!-- /.pc-main -->\n\n </div>\n</div>\n\n<!-- ===== 编辑商品 drawer (在商品库内点编辑触发,prefilled) ===== -->\n<div class=\"drawer-bg\" id=\"pc-drawer-bg\"></div>\n<aside class=\"drawer pc-drawer\" id=\"pc-drawer\" role=\"dialog\" aria-label=\"编辑商品\" aria-hidden=\"true\">\n <div class=\"drawer-h\">\n <h3 id=\"pc-drawer-title\">编辑商品</h3>\n <button class=\"x\" type=\"button\" id=\"pc-drawer-close\" aria-label=\"关闭\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6 6l12 12M6 18L18 6\"/></svg>\n </button>\n </div>\n <div class=\"drawer-b\">\n <div class=\"pc-field\">\n <label class=\"pc-field-label\">商品名称<span class=\"req\">*</span></label>\n <input class=\"input\" id=\"pcf-name\" placeholder=\"请输入商品名称(必填)\" maxlength=\"100\">\n </div>\n <div class=\"pc-field-row\">\n <div>\n <label class=\"pc-field-label\">品类<span class=\"req\">*</span></label>\n <select class=\"select\" id=\"pcf-cat\">\n <option>美妆个护</option>\n <option>服饰内衣</option>\n <option>食品饮料</option>\n <option>家居家电</option>\n <option>数码 3C</option>\n <option>个护清洁</option>\n <option>运动户外</option>\n <option>母婴亲子</option>\n </select>\n </div>\n <div>\n <label class=\"pc-field-label\">目标人群<span style=\"color:var(--black-alpha-48);margin-left:2px\">(选填)</span></label>\n <input class=\"input\" id=\"pcf-target\" placeholder=\"例: 22-32 岁女性、敏感肌、办公室通勤\">\n </div>\n </div>\n <div class=\"pc-field\">\n <label class=\"pc-field-label\">商品图片<span style=\"color:var(--black-alpha-48);margin-left:2px\">(<span id=\"pcf-imgs-ct\">6</span>)</span></label>\n <div class=\"pc-imgs\" id=\"pcf-imgs\"></div>\n </div>\n <div class=\"pc-field\">\n <label class=\"pc-field-label\">核心卖点<span class=\"req\">*</span></label>\n <ul class=\"pc-bullets\" id=\"pcf-bullets\">\n <li class=\"add\"><span class=\"num\">+</span><input id=\"pcf-add-input\" placeholder=\"添加新卖点 · 回车确认\"></li>\n </ul>\n </div>\n </div>\n <div class=\"drawer-f\">\n <button class=\"btn\" type=\"button\" id=\"pc-cancel-btn\">取消</button>\n <button class=\"btn btn-primary\" type=\"button\" id=\"pc-save-btn\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 12l5 5L20 6\"/></svg>\n 保存修改\n </button>\n </div>\n</aside>\n\n<!-- ===== 商品库 全屏(无遮罩自适应,多选) ===== -->\n<div class=\"pl-modal-bg\" id=\"pl-modal-bg\">\n <div class=\"pl-modal\">\n <div class=\"pl-modal-h\">\n <h2>商品库</h2>\n <span class=\"ct\" id=\"pl-total-ct\">// 共 7 个商品</span>\n <div class=\"actions\">\n <button class=\"x\" type=\"button\" id=\"pl-close-btn\" aria-label=\"关闭\" style=\"width:32px;height:32px;display:grid;place-items:center;background:transparent;border:0;border-radius:var(--r-sm);cursor:pointer;color:var(--black-alpha-56)\">\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6 6l12 12M6 18L18 6\"/></svg>\n </button>\n </div>\n </div>\n <div class=\"pl-modal-body\">\n <aside class=\"pl-side\">\n <div class=\"pl-side-h\">分类</div>\n <div class=\"pl-side-item active\" data-cat=\"\">全部 <span class=\"ct\">7</span></div>\n <div class=\"pl-side-item\" data-cat=\"美妆个护\">美妆个护 <span class=\"ct\">2</span></div>\n <div class=\"pl-side-item\" data-cat=\"数码 3C\">数码 3C <span class=\"ct\">1</span></div>\n <div class=\"pl-side-item\" data-cat=\"食品饮料\">食品饮料 <span class=\"ct\">2</span></div>\n <div class=\"pl-side-item\" data-cat=\"家居家电\">家居家电 <span class=\"ct\">1</span></div>\n <div class=\"pl-side-item\" data-cat=\"运动户外\">运动户外 <span class=\"ct\">1</span></div>\n </aside>\n <div class=\"pl-main\">\n <div class=\"pl-toolbar\">\n <div class=\"search\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/></svg>\n <input type=\"text\" id=\"pl-search-input\" placeholder=\"搜索商品名\">\n </div>\n <button class=\"btn-new\" type=\"button\" id=\"pl-new-btn\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 5v14M5 12h14\"/></svg>\n 新建商品\n </button>\n </div>\n <div class=\"pl-scroll\">\n <div class=\"pl-grid\" id=\"pl-grid\"></div>\n </div>\n </div>\n </div>\n <div class=\"pl-modal-f\">\n <div class=\"summary\">// 已选 <b id=\"pl-sel-ct\">0</b> 个商品</div>\n <button class=\"btn\" type=\"button\" id=\"pl-cancel-btn\">取消</button>\n <button class=\"btn btn-primary\" type=\"button\" id=\"pl-confirm-btn\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M5 12l5 5L20 7\"/></svg>\n 确认选择\n </button>\n </div>\n </div>\n</div>\n\n\n<script src=\"/exact/assets/icons.js?v=2026052608\"></script>\n<script src=\"/exact/assets/shell.js?v=2026060101\"></script>\n<script src=\"/exact/assets/new-product-drawer.js?v=202605211643\"></script>\n<script>\nShell.render({\n active: 'asset-factory',\n crumbs: [\n { label: '工作台', href: 'index.html' },\n { label: '图片生成', href: 'asset-factory.html' },\n { label: '平台套图' }\n ]\n});\n\n// ─── 商品库数据 (mock,与 products.html 7 个商品对齐) ───\nconst PRODUCTS = [\n { id: 'p1', name: '透真玻尿酸补水面膜', cat: '美妆个护', meta: '熬夜党 · 124 素材' },\n { id: 'p2', name: '南卡 Lite Pro 蓝牙耳机', cat: '数码 3C', meta: '通勤 · 96 素材' },\n { id: 'p3', name: '滋啦速食牛肉面 6 桶装', cat: '食品饮料', meta: '加班 · 96 素材' },\n { id: 'p4', name: '透真清透物理防晒霜', cat: '美妆个护', meta: 'SPF50 · 76 素材' },\n { id: 'p5', name: '三顿半同款冻干咖啡粉', cat: '食品饮料', meta: '提神 · 68 素材' },\n { id: 'p6', name: '小熊 4L 可视空气炸锅', cat: '家居家电', meta: '小户型 · 54 素材' },\n { id: 'p7', name: '露露同款裸感瑜伽裤', cat: '运动户外', meta: '健身房 · 42 素材' },\n];\n\n// State (单选 · 默认全空)\nconst state = {\n selectedProd: null, // string | null\n selectedPlatform: null, // string | null\n count: 4,\n};\nconst UNIT_PRICE = 0.50;\n\n// ─── 商品空间 (左侧栏) 渲染 ───\nlet _psQuery = '';\nfunction renderProdSpace() {\n const listEl = document.getElementById('ps-list');\n const ctEl = document.getElementById('ps-count');\n const allCtEl = document.getElementById('ps-all-ct');\n if (!listEl) return;\n if (ctEl) ctEl.textContent = PRODUCTS.length;\n if (allCtEl) allCtEl.textContent = PRODUCTS.length + ' 个';\n const q = _psQuery.trim();\n const filtered = q\n ? PRODUCTS.filter(p => p.name.includes(q) || p.cat.includes(q))\n : PRODUCTS;\n if (!filtered.length) {\n listEl.innerHTML = `<div class=\"pc-ps-empty\">// NO MATCH<br>试试其他关键词</div>`;\n return;\n }\n listEl.innerHTML = filtered.map(p => `\n <div class=\"pc-prod-item${state.selectedProd === p.id ? ' active' : ''}\" data-id=\"${p.id}\">\n <div class=\"placeholder thumb\"></div>\n <div class=\"body\">\n <div class=\"nm\">${p.name}</div>\n <div class=\"sub\">// ${p.cat}</div>\n </div>\n </div>\n `).join('');\n listEl.querySelectorAll('.pc-prod-item').forEach(el => {\n el.addEventListener('click', () => selectProduct(el.dataset.id));\n });\n}\n\n// 选中商品 (sidebar 单选 · 同步更新表单/预览/Cost)\nfunction selectProduct(id) {\n state.selectedProd = id;\n document.querySelectorAll('.pc-prod-item').forEach(el => {\n el.classList.toggle('active', el.dataset.id === id);\n });\n updateCurProdHeader();\n if (typeof renderBatchesForCurrentProd === 'function') renderBatchesForCurrentProd();\n const p = PRODUCTS.find(x => x.id === id);\n document.getElementById('pv-prod').textContent = p ? p.name : '未选择';\n updateCost();\n renderPreviewSections();\n}\n\n// 当前商品 header strip\nfunction updateCurProdHeader() {\n const nmEl = document.getElementById('cur-prod-nm');\n const statsEl = document.getElementById('cur-prod-stats');\n const batchesEl = document.getElementById('cur-prod-batches');\n if (!nmEl) return;\n const p = state.selectedProd ? PRODUCTS.find(x => x.id === state.selectedProd) : null;\n if (!p) {\n nmEl.textContent = '未选择 · 请在左侧商品空间选一个';\n nmEl.classList.add('placeholder');\n if (statsEl) statsEl.hidden = true;\n } else {\n nmEl.textContent = p.name;\n nmEl.classList.remove('placeholder');\n const ct = (window._countBatchesForProd ? window._countBatchesForProd(p.id) : 0);\n if (batchesEl) batchesEl.textContent = ct;\n if (statsEl) statsEl.hidden = false;\n }\n}\n\n// 保留旧函数名 alias (兼容旧 call site)\nfunction renderSelectedProds() {\n renderProdSpace();\n updateCurProdHeader();\n const p = state.selectedProd ? PRODUCTS.find(x => x.id === state.selectedProd) : null;\n document.getElementById('pv-prod').textContent = p ? p.name : '未选择';\n updateCost();\n renderPreviewSections();\n}\n\n// ─── 商品库全屏弹窗 (单选) ───\nlet _plDraft = null;\nlet _plCatFilter = '';\nlet _plQuery = '';\n\nfunction renderProdLib() {\n const grid = document.getElementById('pl-grid');\n let list = PRODUCTS;\n if (_plCatFilter) list = list.filter(p => p.cat === _plCatFilter);\n if (_plQuery) list = list.filter(p => p.name.includes(_plQuery));\n grid.innerHTML = list.map(p => `\n <div class=\"pl-card${_plDraft === p.id ? ' selected' : ''}\" data-id=\"${p.id}\">\n <div class=\"pl-card-actions\">\n <button class=\"pl-act\" type=\"button\" data-edit=\"${p.id}\" title=\"编辑商品\" aria-label=\"编辑\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 20h9\"/><path d=\"M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4 12.5-12.5z\"/></svg>\n </button>\n <button class=\"pl-act danger\" type=\"button\" data-del=\"${p.id}\" title=\"删除商品\" aria-label=\"删除\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg>\n </button>\n </div>\n <div class=\"pl-check\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></div>\n <div class=\"placeholder pl-thumb\"><span class=\"ph-frame\">${p.name}</span></div>\n <div class=\"pl-name\">${p.name}</div>\n <div class=\"pl-meta\">${p.cat} · ${p.meta}</div>\n </div>\n `).join('');\n grid.querySelectorAll('.pl-card').forEach(card => {\n card.addEventListener('click', e => {\n if (e.target.closest('[data-edit]') || e.target.closest('[data-del]')) return;\n const id = card.dataset.id;\n // 单选: 选中当前,取消其他\n _plDraft = (_plDraft === id) ? null : id;\n grid.querySelectorAll('.pl-card').forEach(c => c.classList.toggle('selected', c.dataset.id === _plDraft));\n document.getElementById('pl-sel-ct').textContent = _plDraft ? 1 : 0;\n });\n });\n grid.querySelectorAll('[data-edit]').forEach(btn => {\n btn.addEventListener('click', e => {\n e.stopPropagation();\n openEditProductDrawer(btn.dataset.edit);\n });\n });\n grid.querySelectorAll('[data-del]').forEach(btn => {\n btn.addEventListener('click', e => {\n e.stopPropagation();\n const id = btn.dataset.del;\n const p = PRODUCTS.find(x => x.id === id);\n if (!p) return;\n if (!confirm('确认删除「' + p.name + '」?\\n该操作不可撤销,商品下生成的素材记录也会一并清理。')) return;\n const idx = PRODUCTS.findIndex(x => x.id === id);\n if (idx >= 0) PRODUCTS.splice(idx, 1);\n if (_plDraft === id) _plDraft = null;\n if (state.selectedProd === id) state.selectedProd = null;\n renderProdLib();\n renderSelectedProds();\n Shell.toast('已删除', p.name);\n });\n });\n document.getElementById('pl-sel-ct').textContent = _plDraft ? 1 : 0;\n}\n\n// ─── 编辑商品 drawer (在商品库内 prefill 数据) ───\nconst PRODUCT_EXTRA = {\n p1: { target: '熬夜党 · 25-35 岁女性 · 敏感肌', bullets: ['72h 长效补水', '官方授权正品', '通勤补妆神器'], imgs: 6 },\n p2: { target: '通勤党 · 18-30 岁 · 大学生 / 白领', bullets: ['主动降噪 35dB', '蓝牙 5.4 双设备', '32h 续航'], imgs: 6 },\n p3: { target: '加班党 · 独居青年 · 一人食场景', bullets: ['一杯水即可', '原切牛肉块充足', '6 桶大箱装'], imgs: 6 },\n p4: { target: '通勤防晒 · 油皮 / 敏感肌', bullets: ['SPF50+ PA++++', '物理防晒不刺激', '清透不假白'], imgs: 6 },\n p5: { target: '咖啡入门 · 早八党 · 加班族', bullets: ['冷热水即溶', '原产地豆精选', '24 颗精装'], imgs: 6 },\n p6: { target: '小户型 · 健康饮食 · 新手厨房', bullets: ['4L 大容量', '可视玻璃观察', '一键预设'], imgs: 6 },\n p7: { target: '健身房 · 通勤穿搭 · 18-32 岁女性', bullets: ['裸感面料', '高弹收腹', '亲肤透气'], imgs: 6 },\n};\n\n// 渲染商品图片 grid · n 张占位 + 上传按钮\nfunction renderProdImgs(n) {\n const grid = document.getElementById('pcf-imgs');\n const ct = document.getElementById('pcf-imgs-ct');\n if (!grid) return;\n if (ct) ct.textContent = n;\n let html = '';\n for (let i = 0; i < n; i++) {\n html += `<div class=\"thumb\"><span class=\"ph-frame\">1:1</span><button class=\"rm\" type=\"button\" title=\"删除\" data-idx=\"${i}\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"><path d=\"M6 6l12 12M6 18L18 6\"/></svg></button></div>`;\n }\n html += `<div class=\"img-upload\" id=\"pcf-img-add\" title=\"上传图片\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 5v14M5 12h14\"/></svg></div>`;\n grid.innerHTML = html;\n grid.querySelectorAll('.thumb .rm').forEach(btn => {\n btn.addEventListener('click', () => {\n btn.closest('.thumb').remove();\n if (ct) ct.textContent = grid.querySelectorAll('.thumb').length;\n });\n });\n const addBtn = document.getElementById('pcf-img-add');\n if (addBtn) addBtn.addEventListener('click', () => {\n if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('上传图片', '// 演示版暂不支持真实上传');\n });\n}\n\nlet _editingProdId = null;\n\nfunction openEditProductDrawer(id) {\n const p = PRODUCTS.find(x => x.id === id);\n if (!p) return;\n _editingProdId = id;\n document.getElementById('pc-drawer-title').textContent = '编辑商品 · ' + p.name;\n document.getElementById('pcf-name').value = p.name;\n document.getElementById('pcf-cat').value = p.cat;\n const extra = PRODUCT_EXTRA[id] || { target: '', bullets: [], imgs: 0 };\n document.getElementById('pcf-target').value = extra.target || '';\n // 渲染商品图片 (n 张占位)\n renderProdImgs(typeof extra.imgs === 'number' ? extra.imgs : 6);\n const ul = document.getElementById('pcf-bullets');\n ul.querySelectorAll('li:not(.add)').forEach(li => li.remove());\n const addLi = ul.querySelector('.add');\n (extra.bullets || []).forEach((b, i) => {\n const li = document.createElement('li');\n li.innerHTML = `\n <span class=\"num\">${i + 1}</span>\n <input value=\"${b.replace(/\"/g, '"')}\">\n <button class=\"rm\" type=\"button\" aria-label=\"删除\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6 6l12 12M6 18L18 6\"/></svg></button>\n `;\n ul.insertBefore(li, addLi);\n li.querySelector('.rm').addEventListener('click', () => { li.remove(); renumberBullets(); });\n });\n document.getElementById('pcf-add-input').value = '';\n document.getElementById('pc-drawer-bg').classList.add('show');\n document.getElementById('pc-drawer').classList.add('show');\n document.getElementById('pc-drawer').setAttribute('aria-hidden', 'false');\n}\n\nfunction renumberBullets() {\n const ul = document.getElementById('pcf-bullets');\n [...ul.querySelectorAll('li:not(.add) .num')].forEach((s, i) => { s.textContent = i + 1; });\n}\n\nfunction closeEditProductDrawer() {\n document.getElementById('pc-drawer-bg').classList.remove('show');\n document.getElementById('pc-drawer').classList.remove('show');\n document.getElementById('pc-drawer').setAttribute('aria-hidden', 'true');\n _editingProdId = null;\n}\n\ndocument.getElementById('pcf-add-input').addEventListener('keydown', e => {\n if (e.key !== 'Enter') return;\n const v = e.target.value.trim();\n if (!v) return;\n const ul = document.getElementById('pcf-bullets');\n const addLi = ul.querySelector('.add');\n const li = document.createElement('li');\n li.innerHTML = `\n <span class=\"num\">0</span>\n <input value=\"${v.replace(/\"/g, '"')}\">\n <button class=\"rm\" type=\"button\" aria-label=\"删除\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6 6l12 12M6 18L18 6\"/></svg></button>\n `;\n ul.insertBefore(li, addLi);\n li.querySelector('.rm').addEventListener('click', () => { li.remove(); renumberBullets(); });\n e.target.value = '';\n renumberBullets();\n});\n\ndocument.getElementById('pc-drawer-close').addEventListener('click', closeEditProductDrawer);\ndocument.getElementById('pc-cancel-btn').addEventListener('click', closeEditProductDrawer);\ndocument.getElementById('pc-drawer-bg').addEventListener('click', closeEditProductDrawer);\ndocument.getElementById('pc-save-btn').addEventListener('click', () => {\n if (!_editingProdId) return;\n const newName = document.getElementById('pcf-name').value.trim();\n const newCat = document.getElementById('pcf-cat').value;\n const newTarget = document.getElementById('pcf-target').value.trim();\n if (!newName) { Shell.toast('请填写商品名称'); return; }\n const p = PRODUCTS.find(x => x.id === _editingProdId);\n if (p) { p.name = newName; p.cat = newCat; }\n const bullets = [...document.querySelectorAll('#pcf-bullets li:not(.add) input')].map(i => i.value.trim()).filter(Boolean);\n const imgs = document.querySelectorAll('#pcf-imgs .thumb').length;\n PRODUCT_EXTRA[_editingProdId] = { target: newTarget, bullets, imgs };\n Shell.toast('已保存', newName);\n closeEditProductDrawer();\n renderProdLib();\n renderSelectedProds();\n});\n\n// 全部商品 入口 (左侧栏底部 · 打开商品库 modal)\nfunction openProdLibModal() {\n _plDraft = state.selectedProd;\n _plCatFilter = '';\n _plQuery = '';\n document.getElementById('pl-search-input').value = '';\n document.querySelectorAll('.pl-side-item').forEach(x => x.classList.toggle('active', x.dataset.cat === ''));\n renderProdLib();\n document.getElementById('pl-modal-bg').classList.add('show');\n}\ndocument.getElementById('ps-all-btn').addEventListener('click', openProdLibModal);\n\n// 商品空间 · 搜索框 · 新建按钮\ndocument.getElementById('ps-search-input').addEventListener('input', e => {\n _psQuery = e.target.value;\n renderProdSpace();\n});\ndocument.getElementById('ps-new-btn').addEventListener('click', () => {\n if (!window.NewProductDrawer) { Shell.toast('Drawer 未加载'); return; }\n window.NewProductDrawer.open({\n onSave: function (p) {\n const product = {\n id: p.id,\n name: p.name,\n cat: p.cat,\n meta: (p.target ? p.target.split(/[ ,、、]+/)[0] : '新建') + ' · ' + p.imgs + ' 张图',\n };\n PRODUCTS.unshift(product);\n renderProdSpace();\n selectProduct(product.id);\n Shell.toast('已加入商品库', '+ ' + product.name);\n }\n });\n});\ndocument.getElementById('pl-close-btn').addEventListener('click', () => {\n document.getElementById('pl-modal-bg').classList.remove('show');\n});\ndocument.getElementById('pl-cancel-btn').addEventListener('click', () => {\n document.getElementById('pl-modal-bg').classList.remove('show');\n});\ndocument.getElementById('pl-confirm-btn').addEventListener('click', () => {\n if (!_plDraft) { Shell.toast('请先选择商品', '只能选 1 个'); return; }\n document.getElementById('pl-modal-bg').classList.remove('show');\n selectProduct(_plDraft);\n});\ndocument.getElementById('pl-new-btn').addEventListener('click', () => {\n if (!window.NewProductDrawer) { Shell.toast('Drawer 未加载'); return; }\n window.NewProductDrawer.open({\n onSave: function (p) {\n const product = {\n id: p.id,\n name: p.name,\n cat: p.cat,\n meta: (p.target ? p.target.split(/[ ,、、]+/)[0] : '新建') + ' · ' + p.imgs + ' 张图',\n };\n PRODUCTS.unshift(product);\n _plDraft = product.id;\n _plCatFilter = '';\n _plQuery = '';\n const searchInput = document.getElementById('pl-search-input');\n if (searchInput) searchInput.value = '';\n renderProdLib();\n renderProdSpace();\n selectProduct(product.id);\n Shell.toast('已加入商品库', '+ ' + product.name);\n }\n });\n});\ndocument.querySelectorAll('.pl-side-item').forEach(item => {\n item.addEventListener('click', () => {\n document.querySelectorAll('.pl-side-item').forEach(x => x.classList.remove('active'));\n item.classList.add('active');\n _plCatFilter = item.dataset.cat;\n renderProdLib();\n });\n});\ndocument.getElementById('pl-search-input').addEventListener('input', e => {\n _plQuery = e.target.value.trim();\n renderProdLib();\n});\n\n// 平台单选\nfunction updatePlatforms() {\n const id = state.selectedPlatform;\n const c = id ? document.querySelector('.platform-card[data-id=\"' + id + '\"]') : null;\n document.getElementById('pv-platforms').textContent = c ? c.dataset.name : '未选择';\n updateCost();\n renderPreviewSections();\n}\nfunction updateCost() {\n const hasProd = !!state.selectedProd;\n const hasPlat = !!state.selectedPlatform;\n const total = (hasProd && hasPlat ? 1 : 0) * state.count * UNIT_PRICE;\n document.getElementById('cost-total').textContent = '¥' + total.toFixed(2);\n const btn = document.getElementById('pc-go-btn');\n if (!hasProd || !hasPlat) btn.classList.add('disabled');\n else btn.classList.remove('disabled');\n document.getElementById('pv-count').textContent = state.count + ' 张';\n}\ndocument.querySelectorAll('.platform-card').forEach(card => {\n card.addEventListener('click', () => {\n const id = card.dataset.id;\n state.selectedPlatform = (state.selectedPlatform === id) ? null : id;\n document.querySelectorAll('.platform-card').forEach(c =>\n c.classList.toggle('selected', c.dataset.id === state.selectedPlatform)\n );\n updatePlatforms();\n });\n});\n\n// 数量\ndocument.querySelectorAll('.pill-row').forEach(row => {\n row.addEventListener('click', e => {\n const btn = e.target.closest('.opt');\n if (!btn) return;\n row.querySelectorAll('.opt').forEach(o => o.classList.remove('active'));\n btn.classList.add('active');\n state.count = +btn.dataset.val;\n updateCost();\n renderPreviewSections();\n });\n});\n\n// ─── 预览区空态 / 内容 切换 ───\nfunction showPreviewEmpty() {\n const empty = document.getElementById('pv-empty');\n const sum = document.getElementById('pv-summary');\n const results = document.getElementById('pv-results');\n const foot = document.getElementById('pv-foot');\n if (empty) empty.hidden = false;\n if (sum) sum.hidden = true;\n if (results) results.hidden = true;\n if (foot) foot.hidden = true;\n}\nfunction showPreviewContent() {\n const empty = document.getElementById('pv-empty');\n const sum = document.getElementById('pv-summary');\n const results = document.getElementById('pv-results');\n const foot = document.getElementById('pv-foot');\n if (empty) empty.hidden = true;\n if (sum) sum.hidden = false;\n if (results) results.hidden = false;\n if (foot) foot.hidden = false;\n // pv-batch 由 renderResultCards 单独控制\n}\n\n// 渲染右侧预览 (占位 — 切平台/数量时显示)\nfunction renderPreviewSections() {\n const container = document.getElementById('pv-results');\n const id = state.selectedPlatform;\n const c = id ? document.querySelector('.platform-card[data-id=\"' + id + '\"]') : null;\n if (!c) {\n container.innerHTML = '<div class=\"pc-pv-empty\">请先选择平台<div class=\"mono\">// PICK A PLATFORM</div></div>';\n return;\n }\n container.innerHTML = `\n <div class=\"pv-platform-section\">\n <div class=\"ps-h\">\n <span>${c.dataset.name} 套图预览</span>\n <span class=\"ct\">${state.count} 张</span>\n </div>\n <div class=\"ps-grid\">\n ${Array(state.count).fill(0).map(() => `\n <div class=\"mp-result placeholder-only\">\n <div class=\"mp-r-thumb\"><span class=\"ph-frame\">待生成</span></div>\n </div>\n `).join('')}\n </div>\n </div>\n `;\n}\n\n// 渲染生成结果 (点立即生成时调,带 hover overlay + 批量 bar)\nconst RERUN_SVG = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"23 4 23 10 17 10\"/><polyline points=\"1 20 1 14 7 14\"/><path d=\"M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15\"/></svg>';\nconst ADOPT_SVG = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"/></svg>';\nconst CELL_RERUN_SVG = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5\"/></svg>';\nconst CELL_DL_SVG = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3\"/></svg>';\nconst CELL_MORE_SVG = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"5\" cy=\"12\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"12\" cy=\"12\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"19\" cy=\"12\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/></svg>';\nconst CELL_ADOPT_SVG = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z\"/></svg>';\nconst CELL_DEL_SVG = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg>';\nconst CELL_EDIT_SVG = '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 20h9\"/><path d=\"M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4z\"/></svg>';\n\nlet _batchSeq = 0;\nfunction appendBatch(n, kind) {\n const container = document.getElementById('pv-results');\n const id = state.selectedPlatform;\n const c = id ? document.querySelector('.platform-card[data-id=\"' + id + '\"]') : null;\n if (!c) return;\n _batchSeq += 1;\n const ts = new Date();\n const tsStr = `${String(ts.getHours()).padStart(2,'0')}:${String(ts.getMinutes()).padStart(2,'0')}:${String(ts.getSeconds()).padStart(2,'0')}`;\n const labCls = kind === 'gen' ? 'gen' : 'rerun';\n const labTxt = kind === 'gen' ? `批次 ${_batchSeq} · 初始生成` : (kind === 'rerun-all' ? `批次 ${_batchSeq} · 全部重跑` : `批次 ${_batchSeq} · 单张重跑`);\n const section = document.createElement('div');\n section.className = 'pv-platform-section';\n section.dataset.kind = kind;\n section.dataset.ts = String(ts.getTime());\n section.dataset.platformId = id || '';\n section.dataset.platformName = c.dataset.name || '';\n section.dataset.search = [labTxt, c.dataset.name || '', n + '张'].join(' ').toLowerCase();\n section.innerHTML = `\n <div class=\"ps-h\">\n <span class=\"batch-lab ${labCls}\">${labTxt}</span>\n <span class=\"sep\">·</span>\n <span>${c.dataset.name} 套图</span>\n <span class=\"ct\">${n} 张 · ${tsStr}</span>\n </div>\n <div class=\"ps-grid\">\n ${Array(n).fill(0).map((_, i) => `\n <div class=\"mp-result gen\" data-idx=\"${i}\">\n <div class=\"mp-r-thumb\"><span class=\"ph-frame\">${c.dataset.name}</span></div>\n <span class=\"adopt-badge\">已采用</span>\n <div class=\"cell-feedback\" aria-hidden=\"true\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"/></svg>\n <span>已采用</span>\n </div>\n <div class=\"cell-ops\">\n <button class=\"r-rerun\" type=\"button\" title=\"再次生成\" aria-label=\"再次生成\">${CELL_RERUN_SVG}</button>\n <button class=\"r-dl\" type=\"button\" title=\"下载\" aria-label=\"下载\">${CELL_DL_SVG}</button>\n <div class=\"cell-more-wrap\">\n <button class=\"r-more\" type=\"button\" title=\"更多\" aria-label=\"更多\">${CELL_MORE_SVG}</button>\n <div class=\"cell-more-menu\">\n <button class=\"r-adopt\" type=\"button\">${CELL_ADOPT_SVG}<span>加入资产库</span></button>\n <button class=\"r-del danger\" type=\"button\">${CELL_DEL_SVG}<span>删除</span></button>\n </div>\n </div>\n </div>\n </div>\n `).join('')}\n </div>\n <div class=\"pc-pv-batch batch-foot\">\n <button class=\"pill-btn edit-batch\" type=\"button\" title=\"重新编辑\">\n ${CELL_EDIT_SVG}\n <span>重新编辑</span>\n </button>\n <button class=\"pill-btn rerun-batch\" type=\"button\" title=\"再次生成这一批\">\n ${CELL_RERUN_SVG}\n <span>再次生成</span>\n </button>\n <div class=\"batch-more-wrap\">\n <button class=\"pill-btn icon batch-more\" type=\"button\" title=\"更多\" aria-label=\"更多\">${CELL_MORE_SVG}</button>\n <div class=\"batch-more-menu\" role=\"menu\">\n <button class=\"batch-save-all\" type=\"button\">${CELL_ADOPT_SVG}<span>全部加入资产库</span></button>\n <button class=\"batch-del danger\" type=\"button\">${CELL_DEL_SVG}<span>删除该批结果</span></button>\n </div>\n </div>\n </div>\n `;\n container.appendChild(section);\n section.querySelectorAll('.mp-result.gen').forEach(card => {\n setTimeout(() => card.classList.remove('gen'), 1200);\n });\n section.querySelectorAll('.r-rerun').forEach(b => b.addEventListener('click', e => {\n e.stopPropagation();\n rerunOne(b.closest('.mp-result'));\n }));\n section.querySelectorAll('.r-dl').forEach(b => b.addEventListener('click', e => {\n e.stopPropagation();\n Shell.toast('下载', '已开始下载 · MOCK');\n }));\n // 更多 menu 开/合\n section.querySelectorAll('.r-more').forEach(b => b.addEventListener('click', e => {\n e.stopPropagation();\n const wrap = b.closest('.cell-more-wrap');\n const willOpen = !wrap.classList.contains('open');\n document.querySelectorAll('.pv-platform-section .cell-more-wrap.open').forEach(w => w.classList.remove('open'));\n if (willOpen) wrap.classList.add('open');\n }));\n section.querySelectorAll('.r-adopt').forEach(b => b.addEventListener('click', e => {\n e.stopPropagation();\n const wrap = b.closest('.cell-more-wrap');\n if (wrap) wrap.classList.remove('open');\n adoptOne(b.closest('.mp-result'));\n }));\n section.querySelectorAll('.r-del').forEach(b => b.addEventListener('click', e => {\n e.stopPropagation();\n const wrap = b.closest('.cell-more-wrap');\n if (wrap) wrap.classList.remove('open');\n const card = b.closest('.mp-result');\n const sec = card.closest('.pv-platform-section');\n card.remove();\n if (sec && !sec.querySelectorAll('.mp-result:not(.placeholder-only)').length) sec.remove();\n else updateBatchSummary();\n Shell.toast('已删除');\n }));\n section.querySelector('.edit-batch').addEventListener('click', () => {\n const form = document.querySelector('.pc-form');\n if (form) form.scrollIntoView({ behavior: 'smooth', block: 'start' });\n Shell.toast('重新编辑', '请在左侧调整平台 / 张数后再生成');\n });\n section.querySelector('.rerun-batch').addEventListener('click', () => {\n appendBatch(n, 'rerun-all');\n Shell.toast('再次生成', n + ' 张图重新生成中 · 新批次已追加');\n });\n const _adoptAll = () => {\n const cards = section.querySelectorAll('.mp-result:not(.adopted)');\n if (!cards.length) { Shell.toast('该批次已全部采用'); return; }\n cards.forEach(c => { c.classList.remove('gen'); c.classList.add('adopted'); });\n updateBatchSummary();\n Shell.toast('已全部加入资产库', cards.length + ' 张图入对应商品的 AI 素材 · 扣 ¥' + (cards.length * UNIT_PRICE).toFixed(2));\n };\n // 批次「更多」按钮 → 开/合 menu\n const _bMoreBtn = section.querySelector('.batch-more');\n const _bMoreWrap = section.querySelector('.batch-more-wrap');\n if (_bMoreBtn && _bMoreWrap) {\n _bMoreBtn.addEventListener('click', e => {\n e.stopPropagation();\n const willOpen = !_bMoreWrap.classList.contains('open');\n document.querySelectorAll('.pc-pv-batch .batch-more-wrap.open').forEach(w => w.classList.remove('open'));\n if (willOpen) _bMoreWrap.classList.add('open');\n });\n }\n section.querySelector('.batch-save-all').addEventListener('click', e => {\n e.stopPropagation();\n if (_bMoreWrap) _bMoreWrap.classList.remove('open');\n _adoptAll();\n });\n section.querySelector('.batch-del').addEventListener('click', e => {\n e.stopPropagation();\n if (_bMoreWrap) _bMoreWrap.classList.remove('open');\n section.remove();\n updateBatchSummary();\n Shell.toast('已删除该批结果');\n });\n section.scrollIntoView({ behavior: 'smooth', block: 'end' });\n updateBatchSummary();\n if (typeof _refreshPlatformMenu === 'function') _refreshPlatformMenu();\n if (typeof applyPvFilters === 'function') applyPvFilters();\n}\nfunction renderResultCards() {\n const container = document.getElementById('pv-results');\n // 首次生成清掉占位 placeholder-batch,保留已有真实批次(再次「立即生成」追加新批次)\n container.querySelectorAll('.placeholder-batch').forEach(el => el.remove());\n appendBatch(state.count, 'gen');\n}\n\nfunction rerunOne(card) {\n if (!card) return;\n appendBatch(1, 'rerun-one');\n}\nfunction adoptOne(card) {\n if (!card || card.classList.contains('adopted')) return;\n card.classList.remove('gen');\n card.classList.add('adopted');\n // spec §4.18 · 就地中央反馈\n card.classList.add('show-feedback');\n setTimeout(() => card.classList.remove('show-feedback'), 1500);\n updateBatchSummary();\n}\n// 点击页面其它位置 → 关闭单图/批次 more menu\ndocument.addEventListener('click', e => {\n if (!e.target.closest('.pv-platform-section .cell-more-wrap')) {\n document.querySelectorAll('.pv-platform-section .cell-more-wrap.open').forEach(w => w.classList.remove('open'));\n }\n if (!e.target.closest('.pc-pv-batch .batch-more-wrap')) {\n document.querySelectorAll('.pc-pv-batch .batch-more-wrap.open').forEach(w => w.classList.remove('open'));\n }\n});\nfunction updateBatchSummary() {\n document.querySelectorAll('#pv-results .pv-platform-section').forEach(section => {\n const cards = section.querySelectorAll('.mp-result:not(.placeholder-only)');\n const adopted = section.querySelectorAll('.mp-result.adopted').length;\n const adoptedEl = section.querySelector('.adopt-batch .adopted');\n const totalEl = section.querySelector('.adopt-batch .total');\n if (adoptedEl) adoptedEl.textContent = adopted;\n if (totalEl) totalEl.textContent = cards.length;\n });\n}\n\n// 立即生成\ndocument.getElementById('pc-go-btn').addEventListener('click', () => {\n if (!state.selectedPlatform || !state.selectedProd) return;\n const container = document.getElementById('pv-results');\n const hasReal = container && container.querySelector('.mp-result-batch:not(.placeholder-batch)');\n const prod = PRODUCTS.find(p => p.id === state.selectedProd);\n if (hasReal) {\n Shell.toast('已追加批次', state.count + ' 张图新增到下方 · 旧批次保留');\n } else {\n Shell.toast('已提交任务', (prod ? prod.name + ' · ' : '') + state.count + ' 张图生成中');\n }\n showPreviewContent();\n renderResultCards();\n});\n\n// ============================================================\n// 工具台头部 · 搜索 / 时间 / 平台 筛选\n// ============================================================\nconst _pvFilter = { time: 'all', platform: 'all', search: '' };\n\nfunction _pvTimeMatch(ts, key) {\n if (key === 'all') return true;\n const now = Date.now();\n const diff = now - Number(ts);\n if (key === '10min') return diff <= 10 * 60 * 1000;\n if (key === '1h') return diff <= 60 * 60 * 1000;\n if (key === 'today') {\n const a = new Date(now); const b = new Date(Number(ts));\n return a.toDateString() === b.toDateString();\n }\n return true;\n}\n\nfunction applyPvFilters() {\n const container = document.getElementById('pv-results');\n if (!container) return;\n const q = (_pvFilter.search || '').trim().toLowerCase();\n container.querySelectorAll('.pv-platform-section:not(.placeholder-batch)').forEach(sec => {\n let ok = true;\n if (!_pvTimeMatch(sec.dataset.ts, _pvFilter.time)) ok = false;\n if (ok && _pvFilter.platform !== 'all' && sec.dataset.platformId !== _pvFilter.platform) ok = false;\n if (ok && q && !(sec.dataset.search || '').includes(q)) ok = false;\n sec.dataset.hidden = ok ? '0' : '1';\n });\n const hasReal = !!container.querySelector('.pv-platform-section:not(.placeholder-batch)');\n const filterActive = _pvFilter.time !== 'all' || _pvFilter.platform !== 'all' || q.length > 0;\n container.querySelectorAll('.placeholder-batch').forEach(ph => {\n ph.dataset.hidden = (hasReal || filterActive) ? '1' : '0';\n });\n}\n\nfunction _refreshPlatformMenu() {\n const menu = document.getElementById('pc-menu-platform');\n if (!menu) return;\n const container = document.getElementById('pv-results');\n const used = new Map();\n if (container) {\n container.querySelectorAll('.pv-platform-section:not(.placeholder-batch)').forEach(s => {\n const id = s.dataset.platformId; const nm = s.dataset.platformName;\n if (id) used.set(id, nm || id);\n });\n }\n const items = ['<button class=\"tb-menu-item' + (_pvFilter.platform === 'all' ? ' active' : '') + '\" type=\"button\" data-val=\"all\">全部平台</button>'];\n if (used.size === 0) {\n items.push('<div class=\"tb-menu-empty\">暂无批次,生成后可按平台筛选</div>');\n } else {\n used.forEach((nm, id) => {\n items.push(`<button class=\"tb-menu-item${_pvFilter.platform === id ? ' active' : ''}\" type=\"button\" data-val=\"${id}\">${nm}</button>`);\n });\n }\n menu.innerHTML = items.join('');\n}\n\nfunction _setChipLabel(chipId, baseLabel, val) {\n const chip = document.getElementById(chipId);\n if (!chip) return;\n if (val === 'all' || !val) chip.classList.remove('active');\n else chip.classList.add('active');\n}\n\nfunction _closeAllMenus(except) {\n document.querySelectorAll('.pc-main-h .tb-menu-wrap.open').forEach(w => {\n if (w !== except) w.classList.remove('open');\n });\n}\n\ndocument.querySelectorAll('.pc-main-h .tb-menu-wrap').forEach(wrap => {\n const chip = wrap.querySelector('.tb-chip');\n chip.addEventListener('click', e => {\n e.stopPropagation();\n const willOpen = !wrap.classList.contains('open');\n _closeAllMenus(wrap);\n wrap.classList.toggle('open', willOpen);\n if (willOpen && wrap.dataset.filter === 'platform') _refreshPlatformMenu();\n });\n});\ndocument.querySelectorAll('#pc-menu-time, #pc-menu-platform').forEach(menu => {\n menu.addEventListener('click', e => {\n const btn = e.target.closest('.tb-menu-item');\n if (!btn) return;\n const val = btn.dataset.val;\n const wrap = menu.closest('.tb-menu-wrap');\n const key = wrap.dataset.filter;\n _pvFilter[key] = val;\n menu.querySelectorAll('.tb-menu-item').forEach(it => it.classList.toggle('active', it === btn));\n wrap.classList.remove('open');\n _setChipLabel(key === 'time' ? 'pc-chip-time' : 'pc-chip-platform', key === 'time' ? '时间' : '平台', val);\n applyPvFilters();\n });\n});\ndocument.addEventListener('click', e => {\n if (!e.target.closest('.pc-main-h .tb-menu-wrap')) _closeAllMenus(null);\n});\n\n(function setupSearch() {\n const wrap = document.getElementById('pc-search-wrap');\n const toggle = document.getElementById('pc-search-toggle');\n const input = document.getElementById('pc-search-input');\n if (!wrap || !toggle || !input) return;\n toggle.addEventListener('click', e => {\n e.stopPropagation();\n const willExpand = !wrap.classList.contains('expanded');\n wrap.classList.toggle('expanded', willExpand);\n if (willExpand) setTimeout(() => input.focus(), 50);\n else {\n input.value = '';\n _pvFilter.search = '';\n applyPvFilters();\n }\n });\n input.addEventListener('input', () => {\n _pvFilter.search = input.value;\n applyPvFilters();\n });\n input.addEventListener('keydown', e => {\n if (e.key === 'Escape') {\n input.value = '';\n _pvFilter.search = '';\n wrap.classList.remove('expanded');\n applyPvFilters();\n }\n });\n})();\n\n// URL ?product=商品名 → 替换默认选中(从 products.html 跳过来时携带)\n(function applyUrlProduct() {\n const q = new URLSearchParams(location.search);\n const productName = q.get('product');\n if (!productName) return;\n let p = PRODUCTS.find(x => x.name === productName);\n if (!p) {\n p = { id: 'np-' + Date.now().toString(36), name: productName, cat: '美妆个护', meta: '新建 · 待补充' };\n PRODUCTS.unshift(p);\n }\n state.selectedProd = p.id;\n})();\n\n/* ============================================================\n 生成批次 (localStorage) · 按当前商品过滤 · 立即生成时追加\n ============================================================ */\n(function () {\n 'use strict';\n const TASK_TYPE = 'platform';\n const KEY = 'fs-image-tasks-' + TASK_TYPE;\n\n let tasks = [];\n\n function load() {\n try { return JSON.parse(localStorage.getItem(KEY) || '[]'); } catch (e) { return []; }\n }\n function save(arr) {\n try { localStorage.setItem(KEY, JSON.stringify(arr)); } catch (e) {}\n }\n function buildSnapshot() {\n return {\n selectedProd: state.selectedProd,\n selectedPlatform: state.selectedPlatform,\n count: state.count,\n };\n }\n function timeNow() {\n const d = new Date();\n return ('0'+(d.getMonth()+1)).slice(-2) + '.' + ('0'+d.getDate()).slice(-2) + ' ' + ('0'+d.getHours()).slice(-2) + ':' + ('0'+d.getMinutes()).slice(-2);\n }\n\n // 暴露给上层 (给 cur-prod header 用)\n window._countBatchesForProd = function (prodId) {\n return tasks.filter(t => t.snap && t.snap.selectedProd === prodId).length;\n };\n\n // 切换商品 → 清空 pv-results (历史批次只在当前 session 持有)\n window.renderBatchesForCurrentProd = function () {\n const container = document.getElementById('pv-results');\n if (!container) return;\n container.innerHTML = '';\n showPreviewEmpty();\n };\n\n // 立即生成 → push 新批次 + persist + 刷新 cur-prod 计数\n document.getElementById('pc-go-btn').addEventListener('click', () => {\n if (!state.selectedPlatform || !state.selectedProd) return;\n const snap = buildSnapshot();\n const _prod = PRODUCTS.find(p => p.id === state.selectedProd);\n const _platCard = document.querySelector('.platform-card[data-id=\"' + state.selectedPlatform + '\"]');\n const _platName = (_platCard && _platCard.dataset.name) || '平台';\n const _name = ((_prod && _prod.name) || '商品') + ' × ' + _platName;\n const task = {\n id: 'task-' + Date.now(),\n type: TASK_TYPE,\n name: _name,\n snap,\n status: 'ok',\n time: timeNow(),\n createdAt: Date.now(),\n };\n tasks.push(task);\n save(tasks);\n updateCurProdHeader();\n });\n\n tasks = load();\n})();\n\n/* ---------- 商品空间折叠 / 展开 ---------- */\n(function () {\n const layout = document.querySelector('.pc-layout');\n const foldBtn = document.querySelector('.pc-side-top .fold');\n const restoreBtn = document.getElementById('pc-side-restore');\n function setSideCollapsed(collapsed) {\n layout?.classList.toggle('side-collapsed', collapsed);\n if (restoreBtn) restoreBtn.hidden = !collapsed;\n if (foldBtn) foldBtn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');\n try { localStorage.setItem('fs-pc-side-collapsed', collapsed ? '1' : '0'); } catch (e) {}\n }\n foldBtn?.addEventListener('click', () => setSideCollapsed(true));\n restoreBtn?.addEventListener('click', () => setSideCollapsed(false));\n try { setSideCollapsed(localStorage.getItem('fs-pc-side-collapsed') === '1'); } catch (e) {}\n})();\n\n// 初始\nrenderProdSpace();\nrenderSelectedProds();\nupdatePlatforms();\nshowPreviewEmpty(); // 默认 → 右侧显示空态\n// 默认选中: URL ?product= 优先, 否则选 PRODUCTS 首位 (= 最近编辑)\nconst defaultProdId = state.selectedProd || (PRODUCTS[0] && PRODUCTS[0].id);\nif (defaultProdId) selectProduct(defaultProdId);\n</script>\n<script src=\"/exact/assets/api-bridge.js?v=2026060114\"></script>\n</body>\n</html>\n",
|
||
"productCreate": "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta name=\"x-airshelf-exact-source\" content=\"product-create.html\">\n<meta charset=\"utf-8\">\n<title>新建商品 · Airshelf</title>\n<meta http-equiv=\"Cache-Control\" content=\"no-store, must-revalidate\">\n<meta http-equiv=\"Pragma\" content=\"no-cache\">\n<meta http-equiv=\"Expires\" content=\"0\">\n<!-- 双保险:JS 没跑也能跳走 (1.5s 后强制回 products.html) -->\n<meta http-equiv=\"refresh\" content=\"1.5; url=products.html?npd=1\">\n<style>\n html,body{background:#f9f9f9;margin:0;height:100%;font-family:'Inter',system-ui,sans-serif;}\n .stub-msg{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;color:#999;font-size:13px;}\n .stub-msg .ttl{font-size:14px;color:#262626;font-weight:500;}\n .stub-msg a{color:#fa5d19;text-decoration:underline;}\n</style>\n</head>\n<body>\n<div class=\"stub-msg\">\n <div class=\"ttl\">正在打开「新建商品」…</div>\n <div>如未自动跳转,<a href=\"products.html?npd=1\">点击这里</a></div>\n</div>\n<script>\n/* ============================================================\n 新建商品 · 重定向 stub\n ----------------------------------------------------------\n 旧版本是一个 3883 行的全屏 drawer 页(legacy 已归档到 _archive/deprecated-pages-20260528/)。\n 现在「新建商品」改为 drawer 形态在原页面弹出,直接访问此 URL 没有意义。\n\n 行为:\n 1. 设置 sessionStorage 标志,通知落地页自动打开 drawer\n 2. 优先回退到 referrer(用户来源页),否则去 products.html\n 3. cache-bust 参数 ?npd=1 强制浏览器重新拉新版宿主页\n ============================================================ */\n(function () {\n try { sessionStorage.setItem('npd-auto-open', '1'); } catch (e) {}\n try { sessionStorage.setItem('auto-open-new-product', '1'); } catch (e) {}\n\n const ref = document.referrer || '';\n const here = location.origin + location.pathname;\n\n // 同源 referrer 且不是自身才回跳, 否则去 products.html\n let target = 'products.html';\n if (ref && ref.indexOf(location.origin) === 0 && ref.split('#')[0].split('?')[0] !== here) {\n target = ref;\n }\n // 加 cache-bust query · 强制浏览器拉新版宿主页,避免再次命中缓存旧版\n const sep = target.indexOf('?') >= 0 ? '&' : '?';\n target = target + sep + 'npd=' + Date.now().toString(36);\n location.replace(target);\n})();\n</script>\n<script src=\"/exact/assets/api-bridge.js?v=2026060114\"></script>\n</body>\n</html>\n",
|
||
"productCreateUpload": "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta name=\"x-airshelf-exact-source\" content=\"product-create-upload.html\">\n<meta charset=\"utf-8\">\n<title>新建商品 · Airshelf</title>\n<script>\n // 已废弃 · 新建商品改为 products.html 上的居中弹窗(Shell.openNewProduct)\n // 直接访问此 URL 时,跳回商品库并自动打开弹窗\n sessionStorage.setItem('auto-open-new-product', '1');\n location.replace('products.html');\n</script>\n</head>\n<body><script src=\"/exact/assets/api-bridge.js?v=2026060114\"></script>\n</body>\n</html>\n",
|
||
"productDetail": "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta name=\"x-airshelf-exact-source\" content=\"product-detail.html\">\n<meta charset=\"utf-8\">\n<meta http-equiv=\"Cache-Control\" content=\"no-cache, no-store, must-revalidate\">\n<meta http-equiv=\"Pragma\" content=\"no-cache\">\n<meta http-equiv=\"Expires\" content=\"0\">\n<title>商品详情 · Airshelf</title>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"/exact/assets/restraint.css?v=2026060101\">\n<style>\n /* ─── 顶部 标题 + 状态 ─── */\n .pd-title {\n display: flex; align-items: center; gap: 12px;\n margin-bottom: 22px;\n }\n .pd-title h1 {\n font-size: 24px; font-weight: 600;\n letter-spacing: -.015em;\n color: var(--accent-black);\n line-height: 1.25;\n }\n .pd-title .status {\n display: inline-flex; align-items: center; gap: 4px;\n padding: 3px 10px;\n background: var(--accent-emerald-bg, #e6f4ec);\n color: var(--accent-emerald, #1f8a51);\n border: 1px solid var(--accent-emerald-bd, #c4e3d1);\n border-radius: var(--r-sm);\n font-size: 11.5px;\n font-weight: 500;\n }\n\n /* ─── 商品信息(含图片) + 快速操作(辅助) · 3 : 2 两栏 · 高度对齐 ─── */\n .pd-overview {\n display: grid;\n grid-template-columns: 3fr 2fr;\n gap: 16px;\n margin-bottom: 24px;\n align-items: stretch;\n }\n .pd-overview .ov-card { height: 100%; box-sizing: border-box; }\n .pd-overview .ov-card {\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n padding: 20px 22px;\n min-width: 0;\n position: relative;\n }\n /* 编辑按钮 · 放在 .ov-h 标题行右侧 (flex item, 不再 absolute) */\n .pd-overview .ov-h { align-items: center; }\n .ov-edit {\n display: inline-flex; align-items: center; gap: 5px;\n height: 28px;\n padding: 0 12px;\n background: var(--surface);\n border: 1px solid var(--black-alpha-12);\n border-radius: var(--r-sm);\n color: var(--black-alpha-72);\n font-size: 12px;\n font-family: inherit;\n cursor: pointer;\n white-space: nowrap;\n transition: border-color var(--t-base), color var(--t-base), background var(--t-base);\n }\n .ov-edit-single { margin-left: auto; }\n .ov-edit:hover {\n border-color: var(--heat-40);\n color: var(--heat);\n background: var(--heat-12);\n }\n .ov-edit svg { width: 12px; height: 12px; }\n .ov-edit.primary {\n background: var(--heat);\n color: var(--accent-white);\n border-color: var(--heat);\n white-space: nowrap;\n }\n .ov-edit.primary:hover { filter: brightness(1.05); background: var(--heat); color: var(--accent-white); }\n .ov-edit:disabled { cursor: not-allowed; color: var(--heat); border-color: var(--heat-40); background: var(--heat-12); opacity: 1; }\n .ov-edit:disabled:hover { color: var(--heat); border-color: var(--heat-40); background: var(--heat-12); }\n .ov-edit:disabled svg { color: var(--heat); }\n\n /* 编辑模式按钮组 (重置 + 取消 + 保存) */\n .ov-edit-group {\n display: none;\n align-items: center;\n gap: 6px;\n margin-left: auto;\n }\n .ov-card.editing .ov-edit-single { display: none; }\n .ov-card.editing .ov-edit-group { display: inline-flex; }\n\n /* AI 生成三视图 · 按钮 + 弹出 panel(布局复刻 pipeline.html stage 2 三视图预览) */\n .ov-tri-wrap { position: relative; margin-left: auto; }\n /* 当 AI 入口存在时,编辑信息按钮不再独占 ml-auto,与 AI 按钮紧贴 */\n .ov-tri-wrap + .ov-edit-single { margin-left: 0; }\n .ov-tri-trigger { white-space: nowrap; }\n .ov-tri-trigger.is-open { border-color: var(--heat); color: var(--heat); background: var(--heat-12); }\n .ov-card.editing .ov-tri-wrap { display: none; }\n\n .ov-tri-pop {\n position: absolute;\n top: calc(100% + 6px); right: 0;\n width: 360px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n box-shadow: 0 8px 24px rgba(0,0,0,.10), 0 2px 6px rgba(0,0,0,.06);\n padding: 14px 14px 12px;\n display: none;\n flex-direction: column;\n gap: 10px;\n z-index: 40;\n }\n .ov-tri-pop.show { display: flex; }\n .ov-tri-pop::before {\n content: ''; position: absolute;\n top: -5px; right: 36px;\n width: 9px; height: 9px;\n background: var(--surface);\n border-left: 1px solid var(--border-faint);\n border-top: 1px solid var(--border-faint);\n transform: rotate(45deg);\n }\n .ov-tri-close {\n position: absolute;\n top: 8px; right: 8px;\n width: 22px; height: 22px;\n display: grid; place-items: center;\n background: transparent;\n border: 1px solid transparent;\n border-radius: var(--r-sm);\n color: var(--black-alpha-56);\n cursor: pointer;\n transition: background var(--t-base), color var(--t-base), border-color var(--t-base);\n z-index: 2;\n }\n .ov-tri-close:hover { background: var(--black-alpha-08); color: var(--accent-black); border-color: var(--border-faint); }\n .ov-tri-close svg { width: 12px; height: 12px; }\n\n /* 复刻 pipeline.html .prod-preview-* 内部样式 */\n .ov-tri-pop .prod-preview-h { display: flex; align-items: center; gap: 8px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-56); letter-spacing: .04em; text-transform: uppercase; padding-right: 28px; }\n .ov-tri-pop .prod-preview-img { aspect-ratio: 16/9; }\n .ov-tri-pop .prod-preview-foot { display: flex; align-items: center; gap: 8px; min-height: 30px; }\n .ov-tri-pop .prod-preview-history { display: none; flex-direction: column; gap: 6px; }\n .ov-tri-pop .prod-preview-history.show { display: flex; }\n .ov-tri-pop .prod-preview-history .h-lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }\n .ov-tri-pop .prod-preview-history .h-lbl .ct { color: var(--accent-black); font-weight: 600; }\n .ov-tri-pop .prod-preview-history .h-row { display: flex; gap: 6px; overflow-x: auto; padding: 2px; scrollbar-width: thin; }\n .ov-tri-pop .prod-preview-history .h-row::-webkit-scrollbar { height: 4px; }\n .ov-tri-pop .prod-preview-history .h-row::-webkit-scrollbar-thumb { background: var(--border-faint); border-radius: 2px; }\n .ov-tri-pop .prod-preview-history .h-thumb {\n flex: 0 0 auto;\n width: 72px; aspect-ratio: 16/9;\n background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm);\n position: relative; cursor: pointer; transition: border-color var(--t-base);\n display: grid; place-items: center; overflow: hidden;\n }\n .ov-tri-pop .prod-preview-history .h-thumb:hover { border-color: var(--heat-40); }\n /* 已采用版本:主橙描边 + 「已采用」徽标 */\n .ov-tri-pop .prod-preview-history .h-thumb.adopted { border-color: var(--heat); border-width: 2px; box-shadow: 0 0 0 2px var(--heat-12); }\n /* 仅预览(未采用):黑色描边,无徽标 */\n .ov-tri-pop .prod-preview-history .h-thumb.previewing { border-color: var(--accent-black); border-width: 2px; }\n .ov-tri-pop .prod-preview-history .h-thumb .v { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-56); letter-spacing: .02em; }\n .ov-tri-pop .prod-preview-history .h-thumb.adopted .v { color: var(--heat); font-weight: 600; }\n .ov-tri-pop .prod-preview-history .h-thumb.previewing .v { color: var(--accent-black); font-weight: 600; }\n .ov-tri-pop .prod-preview-history .h-thumb .badge { position: absolute; top: 2px; left: 2px; font-family: var(--font-mono); font-size: 8.5px; padding: 0 4px; line-height: 12px; background: var(--heat); color: var(--accent-white); border-radius: 2px; letter-spacing: .02em; display: none; }\n .ov-tri-pop .prod-preview-history .h-thumb.adopted .badge { display: block; }\n\n /* 主图可点击放大 */\n .ov-tri-pop .prod-preview-img.is-zoomable { cursor: zoom-in; transition: border-color var(--t-base); position: relative; }\n .ov-tri-pop .prod-preview-img.is-zoomable:hover { border-color: var(--heat-40); }\n .ov-tri-pop .prod-preview-img.is-zoomable::after {\n content: '';\n position: absolute; top: 8px; right: 8px;\n width: 22px; height: 22px;\n background: rgba(21,20,15,.72) url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='11' cy='11' r='7'/><path d='M21 21l-4.3-4.3M8 11h6M11 8v6'/></svg>\") center/14px no-repeat;\n border-radius: var(--r-sm);\n opacity: 0; transition: opacity var(--t-base);\n pointer-events: none;\n }\n .ov-tri-pop .prod-preview-img.is-zoomable:hover::after { opacity: 1; }\n\n /* 三视图放大查看 lightbox */\n #ov-tri-lightbox-bg { z-index: 80; }\n #ov-tri-lightbox-bg .tri-lightbox {\n position: relative;\n width: min(1100px, 92vw);\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n padding: 18px 20px 20px;\n display: flex; flex-direction: column; gap: 12px;\n box-shadow: 0 24px 64px rgba(0,0,0,.24);\n }\n .tri-lightbox-head {\n display: flex; align-items: center; gap: 8px;\n font-family: var(--font-mono);\n font-size: 11px; letter-spacing: .04em; text-transform: uppercase;\n color: var(--black-alpha-56);\n padding-right: 32px;\n }\n .tri-lightbox-head .lb-ver { color: var(--heat); font-weight: 600; }\n .tri-lightbox-head .lb-tag {\n margin-left: 6px;\n padding: 2px 6px;\n background: var(--heat-12); color: var(--heat);\n border-radius: 3px;\n font-size: 10px;\n }\n .tri-lightbox-close {\n position: absolute;\n top: 12px; right: 12px;\n width: 28px; height: 28px;\n display: grid; place-items: center;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--black-alpha-56);\n cursor: pointer;\n transition: background var(--t-base), color var(--t-base), border-color var(--t-base);\n z-index: 2;\n }\n .tri-lightbox-close:hover { background: var(--black-alpha-08); color: var(--accent-black); border-color: var(--black-alpha-12); }\n .tri-lightbox-close svg { width: 14px; height: 14px; }\n .tri-lightbox-img { aspect-ratio: 16/9; width: 100%; }\n .tri-lightbox-foot { display: flex; align-items: center; gap: 8px; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); }\n .tri-lightbox-foot .spc { flex: 1; }\n .tri-lightbox-foot kbd {\n display: inline-block;\n padding: 1px 5px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-bottom-width: 2px;\n border-radius: 3px;\n font-family: var(--font-mono);\n font-size: 10px;\n color: var(--black-alpha-72);\n }\n\n /* 字段 view ↔ edit 状态切换 */\n .v-edit { display: none; }\n .ov-card.editing .v-static { display: none; }\n .ov-card.editing .v-edit { display: block; }\n\n /* 输入控件 · 对齐新建表单 V2.1 规范 */\n .v-input,\n .v-select {\n width: 100%;\n max-width: 100%;\n height: 38px;\n border: 1px solid var(--black-alpha-12);\n border-radius: var(--r-md);\n padding: 0 14px;\n font-size: 13.5px;\n color: var(--accent-black);\n background: var(--background-lighter);\n font-family: inherit;\n outline: none;\n transition: border-color var(--t-base), box-shadow var(--t-base);\n }\n .v-input:focus,\n .v-select:focus {\n border-color: var(--heat-40);\n box-shadow: inset 0 0 0 1px var(--heat-40);\n }\n\n /* 编辑模式下 · 核心卖点 bullet-list (与新建表单完全一致) */\n .v-bullet-list {\n list-style: none;\n padding: 0; margin: 0;\n }\n .v-bullet-list .bl-item,\n .v-bullet-list .bl-add {\n display: flex; align-items: center; gap: 10px;\n padding: 8px 12px;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n margin-bottom: 6px;\n font-size: 13.5px;\n }\n .v-bullet-list .bl-add { background: transparent; border-style: dashed; }\n .v-bullet-list .bl-add:focus-within { border-color: var(--heat-40); background: var(--surface); }\n .v-bullet-list .num {\n width: 22px; height: 22px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--heat);\n font-weight: 700;\n display: grid; place-items: center;\n flex-shrink: 0;\n }\n .v-bullet-list .bl-add .num {\n background: transparent;\n color: var(--heat);\n border-color: var(--heat-40);\n }\n .v-bullet-list .bl-text { flex: 1; color: var(--accent-black); }\n .v-bullet-list .bl-input {\n flex: 1;\n background: transparent; border: 0; outline: none;\n font-size: 13.5px;\n color: var(--accent-black);\n font-family: inherit;\n }\n .v-bullet-list .bl-input::placeholder { color: var(--black-alpha-48); }\n .v-bullet-list .bl-x {\n width: 22px; height: 22px;\n color: var(--black-alpha-48);\n cursor: pointer;\n display: grid; place-items: center;\n border-radius: var(--r-sm);\n transition: color var(--t-base), background var(--t-base);\n }\n .v-bullet-list .bl-x:hover { color: var(--accent-crimson, #c43d3d); background: var(--crimson-bg, #fdebea); }\n .v-bullet-list .bl-x svg { width: 11px; height: 11px; }\n\n /* 编辑模式下,商品图片显示一个 [+ 上传] 占位 */\n .img-upload {\n display: none;\n aspect-ratio: 1;\n border: 1.5px dashed var(--black-alpha-24);\n border-radius: var(--r-sm);\n cursor: pointer;\n place-items: center;\n color: var(--black-alpha-48);\n background: var(--background-lighter);\n transition: border-color var(--t-base), color var(--t-base);\n }\n .img-upload:hover { border-color: var(--heat); color: var(--heat); }\n .img-upload svg { width: 18px; height: 18px; }\n .ov-card.editing .img-upload { display: grid; }\n .ov-card.editing .ov-images-sub .thumb { cursor: pointer; }\n .ov-card.editing .ov-images-sub .thumb::after {\n content: '×';\n position: absolute;\n top: 4px; right: 4px;\n width: 18px; height: 18px;\n background: rgba(0,0,0,.7);\n color: var(--accent-white);\n border-radius: 50%;\n display: grid; place-items: center;\n font-size: 13px;\n line-height: 1;\n }\n .ov-images-sub .thumb { position: relative; }\n .pd-overview .ov-h {\n display: flex; align-items: baseline; gap: 8px;\n margin-bottom: 14px;\n }\n .pd-overview .ov-h .ti {\n font-size: 14px; font-weight: 600;\n color: var(--accent-black);\n }\n .pd-overview .ov-h .ct {\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--black-alpha-48);\n letter-spacing: .02em;\n }\n .pd-overview .ov-h .more {\n margin-left: auto;\n font-size: 12px;\n color: var(--heat);\n cursor: pointer;\n }\n .pd-overview .ov-h .more:hover { text-decoration: underline; }\n\n /* 商品信息卡片内 · 上信息 / 下图片 (堆叠, 图片铺满卡片) */\n .ov-main-grid {\n display: flex;\n flex-direction: column;\n gap: 18px;\n }\n .ov-main-grid > .ov-images-sub {\n padding-top: 18px;\n border-top: 1px solid var(--border-faint);\n }\n .ov-info .row {\n display: flex; gap: 12px;\n margin-bottom: 10px;\n font-size: 13px;\n }\n .ov-info .row:last-child { margin-bottom: 0; }\n .ov-info .k {\n width: 64px;\n flex-shrink: 0;\n color: var(--black-alpha-48);\n font-size: 12.5px;\n }\n .ov-info .v {\n flex: 1; min-width: 0;\n color: var(--accent-black);\n line-height: 1.6;\n }\n .ov-info .v .bullet { display: block; }\n .ov-info .v .bullet::before {\n content: '·';\n color: var(--heat);\n margin-right: 6px;\n font-weight: 700;\n }\n\n /* 商品图片 · 卡片内子 section */\n .ov-images-sub .sub-h {\n display: flex; align-items: baseline; gap: 6px;\n margin-bottom: 10px;\n }\n .ov-images-sub .sub-h .ti {\n font-size: 12.5px; font-weight: 500;\n color: var(--black-alpha-72);\n }\n .ov-images-sub .sub-h .ct {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--black-alpha-48);\n letter-spacing: .02em;\n }\n .ov-images-sub .sub-h .more {\n margin-left: auto;\n font-size: 11.5px;\n color: var(--heat);\n cursor: pointer;\n }\n .ov-images-sub .sub-h .more:hover { text-decoration: underline; }\n .ov-images-sub .grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));\n gap: 8px;\n }\n .ov-images-sub .thumb {\n aspect-ratio: 1;\n border-radius: var(--r-sm);\n overflow: hidden;\n cursor: pointer;\n }\n .ov-images-sub .thumb img { width: 100%; height: 100%; object-fit: cover; }\n\n /* 快速操作 · 2 段:图片生成(3 等比 CTA)+ 视频生成(1 CTA) · 两段等高填充容器 */\n .ov-actions { display: flex; flex-direction: column; }\n .ov-actions .qa-section {\n margin-bottom: 14px;\n display: flex; flex-direction: column;\n flex: 1 1 0; min-height: 0;\n }\n .ov-actions .qa-section:last-child { margin-bottom: 0; }\n .ov-actions .qa-section-h {\n font-family: var(--font-mono);\n font-size: 10.5px;\n color: var(--black-alpha-48);\n letter-spacing: .06em;\n text-transform: uppercase;\n margin-bottom: 8px;\n }\n .ov-actions .qa-row-3 {\n display: grid;\n grid-template-columns: repeat(3, 1fr);\n gap: 8px;\n flex: 1 1 0; min-height: 0;\n }\n .ov-actions .qa-row-1 {\n display: flex;\n flex: 1 1 0; min-height: 0;\n }\n .ov-actions .qa-row-1 .qa-item { width: 100%; }\n .qa-item {\n display: flex; flex-direction: column;\n align-items: center; justify-content: center; gap: 8px;\n padding: 14px 10px;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n cursor: pointer;\n font-size: 12.5px;\n color: var(--accent-black);\n text-align: center;\n transition: border-color var(--t-base), background var(--t-base), color var(--t-base);\n }\n .qa-item:hover { border-color: var(--heat); background: var(--heat-12); color: var(--heat); }\n .qa-item .ic {\n width: 32px; height: 32px;\n display: grid; place-items: center;\n color: var(--heat);\n flex-shrink: 0;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n }\n .qa-item:hover .ic { border-color: var(--heat-20); background: var(--surface); }\n .qa-item .ic svg { width: 16px; height: 16px; }\n .qa-item.primary {\n background: var(--heat);\n color: var(--accent-white);\n border-color: var(--heat);\n }\n .qa-item.primary .ic { background: rgba(255,255,255,.16); color: var(--accent-white); border-color: rgba(255,255,255,.24); }\n .qa-item.primary:hover { color: var(--accent-white); box-shadow: var(--shadow-cta-hover); }\n\n /* 状态 pill 三态(通过/不通过/归档) */\n .asset-card .meta .pill.pass {\n background: var(--accent-emerald-bg, #e6f4ec);\n color: var(--accent-emerald, #1f8a51);\n border: 1px solid var(--accent-emerald-bd, #c4e3d1);\n cursor: pointer;\n }\n .asset-card .meta .pill.fail {\n background: var(--crimson-bg, #fdebea);\n color: var(--accent-crimson, #c43d3d);\n border: 1px solid var(--crimson-bd, #f5c2bf);\n cursor: pointer;\n }\n .asset-card .meta .pill.archive {\n background: var(--background-lighter);\n color: var(--black-alpha-56);\n border: 1px solid var(--border-faint);\n cursor: pointer;\n }\n\n /* ─── Tabs ─── */\n .pd-tabs {\n display: flex; gap: 4px;\n border-bottom: 1px solid var(--border-faint);\n margin-bottom: 18px;\n }\n .pd-tabs .tab {\n padding: 10px 14px;\n font-size: 13.5px;\n color: var(--black-alpha-56);\n background: transparent;\n border: 0;\n border-bottom: 2px solid transparent;\n cursor: pointer;\n font-family: inherit;\n font-weight: 500;\n transition: color var(--t-base), border-color var(--t-base);\n }\n .pd-tabs .tab:hover { color: var(--accent-black); }\n .pd-tabs .tab.active {\n color: var(--accent-black);\n border-bottom-color: var(--heat);\n font-weight: 600;\n }\n .tab-pane { display: none; }\n .tab-pane.active { display: block; }\n\n /* ─── AI 素材 工具栏 ─── */\n .pd-toolbar {\n display: flex; align-items: center; gap: 10px;\n margin-bottom: 14px;\n flex-wrap: wrap;\n }\n .pd-toolbar .total {\n font-size: 14px; font-weight: 600;\n color: var(--accent-black);\n }\n .pd-toolbar .total .ct {\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--black-alpha-48);\n letter-spacing: .02em;\n margin-left: 4px;\n font-weight: 500;\n }\n .pd-toolbar .filter {\n display: inline-flex; align-items: center; gap: 4px;\n height: 30px;\n padding: 0 10px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n cursor: pointer;\n font-size: 12.5px;\n color: var(--black-alpha-72);\n font-family: inherit;\n transition: border-color var(--t-base), color var(--t-base);\n }\n .pd-toolbar .filter:hover { border-color: var(--black-alpha-24); }\n .pd-toolbar .filter.open, .pd-toolbar .filter.filtered { border-color: var(--heat); color: var(--heat); background: var(--heat-12); }\n .pd-toolbar .filter.open svg, .pd-toolbar .filter.filtered svg { opacity: 1; }\n .pd-toolbar .filter svg { width: 10px; height: 10px; opacity: .6; transition: transform var(--t-base); }\n .pd-toolbar .filter.open svg { transform: rotate(180deg); }\n\n /* 筛选下拉 · 挂在 body 上避免被祖先 overflow 裁切 */\n .filter-pop {\n position: fixed;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n padding: 4px;\n box-shadow: 0 6px 20px var(--black-alpha-12);\n z-index: 1500;\n min-width: 130px;\n display: none;\n flex-direction: column;\n }\n .filter-pop.show { display: flex; }\n .filter-pop button {\n background: transparent; border: 0;\n padding: 8px 12px;\n text-align: left;\n font-size: 12.5px;\n color: var(--accent-black);\n cursor: pointer;\n border-radius: var(--r-sm);\n font-family: inherit;\n white-space: nowrap;\n transition: background var(--t-base);\n }\n .filter-pop button:hover { background: var(--background-lighter); }\n .filter-pop button.selected { background: var(--heat-12); color: var(--heat); font-weight: 600; }\n .filter-pop button.selected::before { content: '✓ '; }\n .pd-toolbar .right { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; }\n .pd-toolbar .view-tog {\n display: inline-flex;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n padding: 2px;\n }\n .pd-toolbar .view-tog button {\n width: 28px; height: 26px;\n display: grid; place-items: center;\n border: 0;\n background: transparent;\n color: var(--black-alpha-48);\n cursor: pointer;\n border-radius: 4px;\n }\n .pd-toolbar .view-tog button.active {\n background: var(--accent-black);\n color: var(--accent-white);\n }\n .pd-toolbar .view-tog button svg { width: 13px; height: 13px; }\n\n /* ─── AI 素材 网格 ─── */\n .asset-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));\n gap: 12px;\n }\n /* 列表视图:卡片横排,缩略图缩到 88px */\n .asset-grid.list-view {\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n .asset-grid.list-view .asset-card {\n display: grid;\n grid-template-columns: 88px minmax(0, 1fr);\n gap: 0;\n align-items: center;\n }\n .asset-grid.list-view .asset-card .thumb {\n aspect-ratio: 1;\n width: 88px;\n border-right: 1px solid var(--border-faint);\n }\n .asset-grid.list-view .asset-card .thumb .type-pill {\n font-size: 9.5px;\n padding: 2px 6px;\n top: 4px;\n left: 4px;\n }\n .asset-grid.list-view .asset-card .thumb .ph-frame { font-size: 10px; }\n .asset-grid.list-view .asset-card .meta {\n padding: 10px 14px;\n }\n\n /* 空筛选结果 */\n .empty-filter {\n padding: 56px 24px;\n text-align: center;\n color: var(--black-alpha-48);\n font-family: var(--font-mono);\n font-size: 12.5px;\n letter-spacing: .02em;\n border: 1px dashed var(--border-faint);\n border-radius: var(--r-md);\n background: var(--background-lighter);\n }\n .empty-filter .reset {\n display: inline-block; margin-top: 12px;\n color: var(--heat); cursor: pointer; text-decoration: underline;\n }\n .asset-card {\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n overflow: hidden;\n cursor: pointer;\n transition: border-color var(--t-base), transform var(--t-fast);\n }\n .asset-card:hover { border-color: var(--black-alpha-24); transform: translateY(-1px); }\n .asset-card .thumb {\n aspect-ratio: 3/4;\n position: relative;\n overflow: hidden;\n }\n .asset-card .thumb .type-pill {\n position: absolute; top: 8px; left: 8px;\n padding: 3px 8px;\n background: rgba(0,0,0,.65);\n color: var(--accent-white);\n border-radius: var(--r-sm);\n font-size: 11px;\n font-weight: 500;\n backdrop-filter: blur(4px);\n }\n .asset-card .meta {\n padding: 10px 12px;\n display: flex; align-items: center; gap: 8px;\n }\n .asset-card .meta .pill {\n padding: 2px 8px;\n border-radius: var(--r-sm);\n font-size: 10.5px;\n font-weight: 500;\n }\n .asset-card .meta .date {\n margin-left: auto;\n font-family: var(--font-mono);\n font-size: 10.5px;\n color: var(--black-alpha-48);\n letter-spacing: .02em;\n }\n .pd-more {\n text-align: center;\n padding: 18px 0 32px;\n }\n .pd-more button {\n height: 32px;\n padding: 0 18px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n color: var(--black-alpha-72);\n font-size: 12.5px;\n font-family: inherit;\n cursor: pointer;\n }\n .pd-more button:hover { border-color: var(--heat-40); color: var(--heat); }\n\n /* ─── 任务记录 · 表格 ─── */\n .task-stats {\n display: grid;\n grid-template-columns: repeat(4, 1fr);\n gap: 12px;\n margin-bottom: 18px;\n }\n .task-stat {\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n padding: 14px 18px;\n }\n .task-stat .lbl {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--black-alpha-48);\n letter-spacing: .04em;\n margin-bottom: 6px;\n }\n .task-stat .v {\n font-size: 22px; font-weight: 600;\n color: var(--accent-black);\n letter-spacing: -.01em;\n }\n .task-stat .v small {\n font-size: 13px;\n color: var(--black-alpha-48);\n font-weight: 400;\n margin-left: 4px;\n }\n .task-stat.ok .v { color: var(--accent-emerald, #1f8a51); }\n .task-stat.gen .v { color: var(--heat); }\n .task-stat.err .v { color: var(--accent-crimson, #c43d3d); }\n\n .task-table {\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n overflow: hidden;\n }\n .task-row {\n display: grid;\n grid-template-columns: 36px 1.8fr 0.7fr 1fr 1.1fr 1.1fr 0.7fr 100px;\n align-items: center;\n gap: 12px;\n padding: 12px 18px;\n border-bottom: 1px solid var(--border-faint);\n font-size: 13px;\n }\n .task-row:last-child { border-bottom: 0; }\n .task-row.head {\n background: var(--background-lighter);\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--black-alpha-48);\n letter-spacing: .04em;\n font-weight: 500;\n padding: 10px 18px;\n }\n .task-row .ph {\n width: 36px; height: 36px;\n border-radius: var(--r-sm);\n flex-shrink: 0;\n }\n .task-row .nm {\n color: var(--accent-black);\n font-weight: 500;\n display: flex; align-items: center; gap: 8px;\n }\n .task-row .nm .id-mono {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--black-alpha-48);\n font-weight: 400;\n }\n .task-row .qty { color: var(--black-alpha-72); font-family: var(--font-mono); }\n .task-row .time {\n color: var(--black-alpha-72);\n font-family: var(--font-mono);\n font-size: 12px;\n letter-spacing: .01em;\n }\n .task-row .dur {\n color: var(--black-alpha-56);\n font-family: var(--font-mono);\n font-size: 12px;\n }\n .task-row .pill {\n display: inline-flex; align-items: center; gap: 5px;\n padding: 3px 9px;\n border-radius: var(--r-sm);\n font-size: 11.5px;\n font-weight: 500;\n width: fit-content;\n }\n .task-row .pill .dot {\n width: 6px; height: 6px;\n border-radius: 50%;\n }\n .task-row .pill.ok {\n background: var(--accent-emerald-bg, #e6f4ec);\n color: var(--accent-emerald, #1f8a51);\n border: 1px solid var(--accent-emerald-bd, #c4e3d1);\n }\n .task-row .pill.ok .dot { background: var(--accent-emerald, #1f8a51); }\n .task-row .pill.gen {\n background: var(--heat-12);\n color: var(--heat);\n border: 1px solid var(--heat-20);\n }\n .task-row .pill.gen .dot { background: var(--heat); animation: pulse 1.6s ease-in-out infinite; }\n .task-row .pill.err {\n background: var(--crimson-bg, #fdebea);\n color: var(--accent-crimson, #c43d3d);\n border: 1px solid var(--crimson-bd, #f5c2bf);\n }\n .task-row .pill.err .dot { background: var(--accent-crimson, #c43d3d); }\n .task-row .pill.wait {\n background: var(--background-lighter);\n color: var(--black-alpha-56);\n border: 1px solid var(--border-faint);\n }\n .task-row .pill.wait .dot { background: var(--black-alpha-32); }\n @keyframes pulse {\n 0%, 100% { opacity: 1; }\n 50% { opacity: .3; }\n }\n .task-row .status-cell { display: flex; flex-direction: column; gap: 4px; }\n .task-row .progress {\n width: 100%; height: 3px;\n background: var(--black-alpha-12);\n border-radius: 2px;\n overflow: hidden;\n }\n .task-row .progress > span {\n display: block;\n height: 100%;\n background: var(--heat);\n }\n .task-row .ops {\n display: inline-flex; gap: 4px;\n justify-self: end;\n }\n .task-row .ops button {\n padding: 4px 10px;\n height: 26px;\n background: transparent;\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n color: var(--black-alpha-72);\n font-size: 11.5px;\n font-family: inherit;\n cursor: pointer;\n transition: border-color var(--t-base), color var(--t-base);\n }\n .task-row .ops button:hover { border-color: var(--heat-40); color: var(--heat); }\n .task-row .ops button.danger:hover { border-color: var(--crimson-bd, #f5c2bf); color: var(--accent-crimson, #c43d3d); }\n\n @media (max-width: 1100px) {\n .pd-overview { grid-template-columns: 1fr; }\n .ov-actions .qa-grid {\n display: grid !important;\n grid-template-columns: repeat(3, 1fr);\n }\n }\n @media (max-width: 900px) {\n .ov-actions .qa-grid { grid-template-columns: 1fr 1fr; }\n .task-stats { grid-template-columns: repeat(2, 1fr); }\n }\n</style>\n</head>\n<body>\n<div id=\"page\">\n\n <!-- 顶部 标题 + 状态 -->\n <div class=\"pd-title\">\n <h1 id=\"pd-name\">补水保湿精华液</h1>\n </div>\n\n <!-- 商品信息(含图片) + 快速操作 · 主辅两栏 -->\n <div class=\"pd-overview\">\n\n <div class=\"ov-card ov-main\" id=\"ov-main-card\">\n <div class=\"ov-h\">\n <span class=\"ti\">商品信息</span>\n <!-- AI 生成三视图 · 按钮 + 弹出 panel(view 模式可见) -->\n <div class=\"ov-tri-wrap\">\n <button class=\"ov-edit ov-tri-trigger\" type=\"button\" id=\"ov-tri-btn\" title=\"AI 生成商品三视图\" aria-haspopup=\"dialog\" aria-expanded=\"false\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8L12 3z\"/><path d=\"M19 14l.9 2.1L22 17l-2.1.9L19 20l-.9-2.1L16 17l2.1-.9L19 14z\"/></svg>\n AI 生成三视图\n </button>\n <div class=\"ov-tri-pop\" id=\"ov-tri-pop\" role=\"dialog\" aria-label=\"AI 生成三视图\">\n <button class=\"ov-tri-close\" type=\"button\" id=\"ov-tri-close\" aria-label=\"关闭\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M18 6L6 18M6 6l12 12\"/></svg>\n </button>\n <div class=\"prod-preview-h\">// 三视图预览 · <span id=\"ov-tri-status\">待生成</span></div>\n <div class=\"placeholder prod-preview-img\" id=\"ov-tri-img\"><span class=\"ph-frame\">// 尚未生成 · 点击下方按钮开始</span></div>\n <div class=\"prod-preview-foot\" id=\"ov-tri-foot\">\n <button class=\"ov-edit primary\" type=\"button\" id=\"ov-tri-start\" style=\"height:28px;\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8L12 3z\"/></svg>\n 生成\n </button>\n <span style=\"flex:1;\"></span>\n <span class=\"mono\" style=\"font-size:11px; color: var(--black-alpha-56);\">~¥0.30 / 次</span>\n </div>\n <div class=\"prod-preview-history\" id=\"ov-tri-history\">\n <div class=\"h-lbl\">// 历史版本 · <span class=\"ct\" id=\"ov-tri-history-count\">0</span> 版</div>\n <div class=\"h-row\" id=\"ov-tri-history-row\"></div>\n </div>\n </div>\n </div>\n <!-- view 模式: 单个 [编辑信息] -->\n <button class=\"ov-edit ov-edit-single\" type=\"button\" id=\"ov-edit-btn\" title=\"编辑商品信息\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 20h9\"/><path d=\"M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4L16.5 3.5z\"/></svg>\n 编辑信息\n </button>\n <!-- edit 模式: [重置] [取消] [保存] -->\n <div class=\"ov-edit-group\">\n <button class=\"ov-edit\" type=\"button\" id=\"ov-reset-btn\" title=\"重置为修改前\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 12a9 9 0 1 0 3-6.7\"/><path d=\"M3 4v5h5\"/></svg>\n 重置\n </button>\n <button class=\"ov-edit\" type=\"button\" id=\"ov-cancel-btn\">取消</button>\n <button class=\"ov-edit primary\" type=\"button\" id=\"ov-save-btn\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 12l5 5L20 6\"/></svg>\n 保存\n </button>\n </div>\n </div>\n\n <div class=\"ov-main-grid\">\n\n <div class=\"ov-info\">\n <div class=\"row\" data-field=\"name\">\n <div class=\"k\">商品名称</div>\n <div class=\"v\">\n <span class=\"v-static\">补水保湿精华液</span>\n <input class=\"v-edit v-input\" type=\"text\" value=\"补水保湿精华液\" maxlength=\"100\">\n </div>\n </div>\n <div class=\"row\" data-field=\"cat\">\n <div class=\"k\">品类</div>\n <div class=\"v\">\n <span class=\"v-static\">美妆个护 / 精华液</span>\n <select class=\"v-edit v-select\">\n <option>美妆个护 / 精华液</option>\n <option>美妆个护</option>\n <option>服饰内衣</option>\n <option>食品饮料</option>\n <option>家居家电</option>\n <option>数码 3C</option>\n <option>个护清洁</option>\n <option>运动户外</option>\n <option>母婴亲子</option>\n </select>\n </div>\n </div>\n <div class=\"row\" data-field=\"target\">\n <div class=\"k\">目标人群</div>\n <div class=\"v\">\n <span class=\"v-static\">22-32 岁女性、敏感肌、办公室通勤</span>\n <input class=\"v-edit v-input\" type=\"text\" value=\"22-32 岁女性、敏感肌、办公室通勤\">\n </div>\n </div>\n <div class=\"row\" data-field=\"bullets\">\n <div class=\"k\">核心卖点</div>\n <div class=\"v\">\n <div class=\"v-static\">\n <span class=\"bullet\">透明质酸 + B5,敷完不黏不闷</span>\n <span class=\"bullet\">30g 大容量精华液</span>\n <span class=\"bullet\">0 香精 0 酒精,敏感肌可用</span>\n </div>\n <ul class=\"v-edit v-bullet-list\" id=\"v-bullets-list\">\n <!-- li.bl-item × N + li.bl-add 由 JS 在进入编辑模式时渲染 -->\n </ul>\n </div>\n </div>\n </div>\n\n <div class=\"ov-images-sub\">\n <div class=\"sub-h\">\n <span class=\"ti\">商品图片</span>\n <span class=\"ct\">(6)</span>\n </div>\n <div class=\"grid\" id=\"ov-images-grid\">\n <div class=\"thumb placeholder\"><span class=\"ph-frame\">1:1</span></div>\n <div class=\"thumb placeholder\"><span class=\"ph-frame\">1:1</span></div>\n <div class=\"thumb placeholder\"><span class=\"ph-frame\">1:1</span></div>\n <div class=\"thumb placeholder\"><span class=\"ph-frame\">1:1</span></div>\n <div class=\"thumb placeholder\"><span class=\"ph-frame\">1:1</span></div>\n <div class=\"thumb placeholder\"><span class=\"ph-frame\">1:1</span></div>\n <div class=\"img-upload\" id=\"ov-img-add\" title=\"上传图片\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 5v14M5 12h14\"/></svg>\n </div>\n </div>\n </div>\n\n </div>\n </div>\n\n <div class=\"ov-card ov-actions\">\n <div class=\"ov-h\"><span class=\"ti\">快速操作</span></div>\n <div class=\"qa-section\">\n <div class=\"qa-section-h\">// 图片生成</div>\n <div class=\"qa-row-3\">\n <div class=\"qa-item\" data-go=\"model-photo\" role=\"button\" tabindex=\"0\">\n <span class=\"ic\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"8\" r=\"4\"/><path d=\"M4 21v-2a4 4 0 014-4h8a4 4 0 014 4v2\"/></svg></span>\n 模特上身图\n </div>\n <div class=\"qa-item\" data-go=\"platform-cover\" role=\"button\" tabindex=\"0\">\n <span class=\"ic\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/><path d=\"M3 9h18M9 3v18\"/></svg></span>\n 平台套图\n </div>\n <div class=\"qa-item\" data-go=\"image-optimize\" role=\"button\" tabindex=\"0\">\n <span class=\"ic\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 3v18M3 12h18M5 5l14 14M5 19l14-14\"/></svg></span>\n 图片创作\n </div>\n </div>\n </div>\n <div class=\"qa-section\">\n <div class=\"qa-section-h\">// 视频生成</div>\n <div class=\"qa-row-1\">\n <div class=\"qa-item primary\" data-go=\"projects-new\" role=\"button\" tabindex=\"0\">\n <span class=\"ic\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2\" y=\"6\" width=\"14\" height=\"12\" rx=\"2\"/><path d=\"M16 10l6-3v10l-6-3z\"/></svg></span>\n 生成视频\n </div>\n </div>\n </div>\n </div>\n\n </div>\n\n <!-- Tabs -->\n <div class=\"pd-tabs\">\n <button class=\"tab active\" type=\"button\" data-tab=\"assets\">AI 生成素材</button>\n <button class=\"tab\" type=\"button\" data-tab=\"videos\">视频项目</button>\n <button class=\"tab\" type=\"button\" data-tab=\"tasks\" hidden>任务记录</button>\n </div>\n\n <!-- ===== AI 生成素材 ===== -->\n <div class=\"tab-pane active\" data-pane=\"assets\">\n\n <div class=\"pd-toolbar\">\n <div class=\"total\">全部 AI 素材 <span class=\"ct\">(32)</span></div>\n <button class=\"filter\" type=\"button\" data-key=\"type\">\n 全部类型\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg>\n </button>\n <button class=\"filter\" type=\"button\" data-key=\"status\">\n 通过\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg>\n </button>\n <div class=\"right\">\n <div class=\"view-tog\">\n <button type=\"button\" class=\"active\" title=\"网格视图\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><rect x=\"3\" y=\"3\" width=\"7\" height=\"7\"/><rect x=\"14\" y=\"3\" width=\"7\" height=\"7\"/><rect x=\"3\" y=\"14\" width=\"7\" height=\"7\"/><rect x=\"14\" y=\"14\" width=\"7\" height=\"7\"/></svg>\n </button>\n <button type=\"button\" title=\"列表视图\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M3 6h18M3 12h18M3 18h18\"/></svg>\n </button>\n </div>\n <button class=\"filter\" type=\"button\" data-key=\"sort\">\n 最新生成\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg>\n </button>\n </div>\n </div>\n\n <div class=\"asset-grid\">\n <div class=\"asset-card\"><div class=\"thumb placeholder\"><span class=\"type-pill\">模特上身图</span><span class=\"ph-frame\">3:4</span></div><div class=\"meta\"><span class=\"pill pass\" data-status=\"pass\" title=\"点击切换状态\">通过</span><span class=\"date\">2026-05-19 15:30</span></div></div>\n <div class=\"asset-card\"><div class=\"thumb placeholder\"><span class=\"type-pill\">模特上身图</span><span class=\"ph-frame\">3:4</span></div><div class=\"meta\"><span class=\"pill pass\" data-status=\"pass\" title=\"点击切换状态\">通过</span><span class=\"date\">2026-05-19 15:30</span></div></div>\n <div class=\"asset-card\"><div class=\"thumb placeholder\"><span class=\"type-pill\">模特上身图</span><span class=\"ph-frame\">3:4</span></div><div class=\"meta\"><span class=\"pill fail\" data-status=\"fail\" title=\"点击切换状态\">不通过</span><span class=\"date\">2026-05-19 15:30</span></div></div>\n <div class=\"asset-card\"><div class=\"thumb placeholder\"><span class=\"type-pill\">模特上身图</span><span class=\"ph-frame\">3:4</span></div><div class=\"meta\"><span class=\"pill pass\" data-status=\"pass\" title=\"点击切换状态\">通过</span><span class=\"date\">2026-05-19 15:30</span></div></div>\n <div class=\"asset-card\"><div class=\"thumb placeholder\"><span class=\"type-pill\">模特上身图</span><span class=\"ph-frame\">3:4</span></div><div class=\"meta\"><span class=\"pill archive\" data-status=\"archive\" title=\"点击切换状态\">归档</span><span class=\"date\">2026-05-19 15:30</span></div></div>\n <div class=\"asset-card\"><div class=\"thumb placeholder\"><span class=\"type-pill\">平台套图</span><span class=\"ph-frame\">3:4</span></div><div class=\"meta\"><span class=\"pill pass\" data-status=\"pass\" title=\"点击切换状态\">通过</span><span class=\"date\">2026-05-19 15:30</span></div></div>\n <div class=\"asset-card\"><div class=\"thumb placeholder\"><span class=\"type-pill\">平台套图</span><span class=\"ph-frame\">3:4</span></div><div class=\"meta\"><span class=\"pill pass\" data-status=\"pass\" title=\"点击切换状态\">通过</span><span class=\"date\">2026-05-19 15:30</span></div></div>\n <div class=\"asset-card\"><div class=\"thumb placeholder\"><span class=\"type-pill\">平台套图</span><span class=\"ph-frame\">3:4</span></div><div class=\"meta\"><span class=\"pill fail\" data-status=\"fail\" title=\"点击切换状态\">不通过</span><span class=\"date\">2026-05-19 15:30</span></div></div>\n <div class=\"asset-card\"><div class=\"thumb placeholder\"><span class=\"type-pill\">平台套图</span><span class=\"ph-frame\">3:4</span></div><div class=\"meta\"><span class=\"pill archive\" data-status=\"archive\" title=\"点击切换状态\">归档</span><span class=\"date\">2026-05-19 15:30</span></div></div>\n <div class=\"asset-card\"><div class=\"thumb placeholder\"><span class=\"type-pill\">平台套图</span><span class=\"ph-frame\">3:4</span></div><div class=\"meta\"><span class=\"pill pass\" data-status=\"pass\" title=\"点击切换状态\">通过</span><span class=\"date\">2026-05-19 15:30</span></div></div>\n <div class=\"asset-card\"><div class=\"thumb placeholder\"><span class=\"type-pill\">三视图</span><span class=\"ph-frame\">3:4</span></div><div class=\"meta\"><span class=\"pill pass\" data-status=\"pass\" title=\"点击切换状态\">通过</span><span class=\"date\">2026-05-19 15:30</span></div></div>\n <div class=\"asset-card\"><div class=\"thumb placeholder\"><span class=\"type-pill\">三视图</span><span class=\"ph-frame\">3:4</span></div><div class=\"meta\"><span class=\"pill archive\" data-status=\"archive\" title=\"点击切换状态\">归档</span><span class=\"date\">2026-05-19 15:30</span></div></div>\n </div>\n\n <div class=\"pd-more\"><button type=\"button\">加载更多</button></div>\n </div>\n\n <!-- ===== 视频项目 ===== -->\n <div class=\"tab-pane\" data-pane=\"videos\">\n <div class=\"pd-toolbar\">\n <div class=\"total\">该商品视频项目 <span class=\"ct\">(4)</span></div>\n <div class=\"right\">\n <button class=\"filter\" type=\"button\" data-key=\"sort\">\n 最新导出\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg>\n </button>\n </div>\n </div>\n\n <div class=\"asset-grid\">\n <div class=\"asset-card\" data-proj-status=\"done\"><div class=\"thumb placeholder\" style=\"aspect-ratio: 9/16;\"><span class=\"type-pill\">视频 · 9:16</span><span class=\"ph-frame\">补水面膜 · v3</span></div><div class=\"meta\"><span class=\"pill ok\"><span class=\"dot\"></span>已完成</span><span class=\"date\">2026-05-20 12:08</span></div></div>\n <div class=\"asset-card\" data-proj-status=\"wip\"><div class=\"thumb placeholder\" style=\"aspect-ratio: 9/16;\"><span class=\"type-pill\">视频 · 9:16</span><span class=\"ph-frame\">补水面膜 · v2</span></div><div class=\"meta\"><span class=\"pill info\"><span class=\"dot\"></span>视频生成 4/6</span><span class=\"date\">2026-05-19 10:24</span></div></div>\n <div class=\"asset-card\" data-proj-status=\"archived\"><div class=\"thumb placeholder\" style=\"aspect-ratio: 9/16;\"><span class=\"type-pill\">视频 · 9:16</span><span class=\"ph-frame\">熬夜急救 · v1</span></div><div class=\"meta\"><span class=\"pill neutral\"><span class=\"dot\"></span>已归档</span><span class=\"date\">2026-05-18 21:42</span></div></div>\n <div class=\"asset-card\" data-proj-status=\"fail\"><div class=\"thumb placeholder\" style=\"aspect-ratio: 9/16;\"><span class=\"type-pill\">视频 · 9:16</span><span class=\"ph-frame\">补水面膜 · v1</span></div><div class=\"meta\"><span class=\"pill err\"><span class=\"dot\"></span>故事板失败</span><span class=\"date\">2026-05-17 16:00</span></div></div>\n </div>\n\n <div class=\"pd-more\"><button type=\"button\">加载更多</button></div>\n </div>\n\n <!-- ===== 任务记录 ===== -->\n <div class=\"tab-pane\" data-pane=\"tasks\">\n\n <!-- 顶部统计概览 -->\n <div class=\"task-stats\">\n <div class=\"task-stat\">\n <div class=\"lbl\">// TOTAL</div>\n <div class=\"v\">12 <small>个任务</small></div>\n </div>\n <div class=\"task-stat ok\">\n <div class=\"lbl\">// SUCCESS</div>\n <div class=\"v\">9</div>\n </div>\n <div class=\"task-stat gen\">\n <div class=\"lbl\">// RUNNING</div>\n <div class=\"v\">2</div>\n </div>\n <div class=\"task-stat err\">\n <div class=\"lbl\">// FAILED</div>\n <div class=\"v\">1</div>\n </div>\n </div>\n\n <!-- 工具栏 -->\n <div class=\"pd-toolbar\">\n <div class=\"total\">任务记录 <span class=\"ct\">(12)</span></div>\n <button class=\"filter\" type=\"button\" data-key=\"type\">\n 全部类型\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg>\n </button>\n <button class=\"filter\" type=\"button\" data-key=\"status\">\n 全部状态\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg>\n </button>\n <button class=\"filter\" type=\"button\" data-key=\"date\">\n 近 7 天\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg>\n </button>\n <div class=\"right\">\n <button class=\"filter\" type=\"button\" data-key=\"sort\">\n 提交时间倒序\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg>\n </button>\n </div>\n </div>\n\n <!-- 任务表格 -->\n <div class=\"task-table\">\n <div class=\"task-row head\">\n <span></span>\n <span>任务 / 编号</span>\n <span>数量</span>\n <span>状态</span>\n <span>提交时间</span>\n <span>完成时间</span>\n <span>耗时</span>\n <span style=\"justify-self:end\">操作</span>\n </div>\n\n <div class=\"task-row\">\n <div class=\"ph placeholder\"></div>\n <div class=\"nm\">视频素材 <span class=\"id-mono\">// T-2026-0519-0007</span></div>\n <div class=\"qty\">1 个</div>\n <div class=\"status-cell\">\n <span class=\"pill gen\"><span class=\"dot\"></span>生成中 60%</span>\n <span class=\"progress\"><span style=\"width:60%\"></span></span>\n </div>\n <div class=\"time\">2026-05-19 16:00</div>\n <div class=\"time\">—</div>\n <div class=\"dur\">—</div>\n <div class=\"ops\">\n <button type=\"button\">查看</button>\n <button type=\"button\" class=\"danger\">取消</button>\n </div>\n </div>\n\n <div class=\"task-row\">\n <div class=\"ph placeholder\"></div>\n <div class=\"nm\">模特上身图 <span class=\"id-mono\">// T-2026-0519-0006</span></div>\n <div class=\"qty\">3 张</div>\n <div class=\"status-cell\">\n <span class=\"pill wait\"><span class=\"dot\"></span>排队中</span>\n </div>\n <div class=\"time\">2026-05-19 15:58</div>\n <div class=\"time\">—</div>\n <div class=\"dur\">—</div>\n <div class=\"ops\">\n <button type=\"button\">查看</button>\n <button type=\"button\" class=\"danger\">取消</button>\n </div>\n </div>\n\n <div class=\"task-row\">\n <div class=\"ph placeholder\"></div>\n <div class=\"nm\">模特上身图 <span class=\"id-mono\">// T-2026-0519-0005</span></div>\n <div class=\"qty\">5 张</div>\n <div class=\"status-cell\">\n <span class=\"pill ok\"><span class=\"dot\"></span>已完成</span>\n </div>\n <div class=\"time\">2026-05-19 15:30</div>\n <div class=\"time\">2026-05-19 15:32</div>\n <div class=\"dur\">2m 14s</div>\n <div class=\"ops\">\n <button type=\"button\">查看</button>\n </div>\n </div>\n\n <div class=\"task-row\">\n <div class=\"ph placeholder\"></div>\n <div class=\"nm\">平台套图 <span class=\"id-mono\">// T-2026-0519-0004</span></div>\n <div class=\"qty\">4 张</div>\n <div class=\"status-cell\">\n <span class=\"pill ok\"><span class=\"dot\"></span>已完成</span>\n </div>\n <div class=\"time\">2026-05-19 14:20</div>\n <div class=\"time\">2026-05-19 14:23</div>\n <div class=\"dur\">3m 02s</div>\n <div class=\"ops\">\n <button type=\"button\">查看</button>\n </div>\n </div>\n\n <div class=\"task-row\">\n <div class=\"ph placeholder\"></div>\n <div class=\"nm\">模特上身图 <span class=\"id-mono\">// T-2026-0519-0003</span></div>\n <div class=\"qty\">4 张</div>\n <div class=\"status-cell\">\n <span class=\"pill ok\"><span class=\"dot\"></span>已完成</span>\n </div>\n <div class=\"time\">2026-05-19 13:10</div>\n <div class=\"time\">2026-05-19 13:13</div>\n <div class=\"dur\">2m 50s</div>\n <div class=\"ops\">\n <button type=\"button\">查看</button>\n </div>\n </div>\n\n <div class=\"task-row\">\n <div class=\"ph placeholder\"></div>\n <div class=\"nm\">三视图 <span class=\"id-mono\">// T-2026-0519-0002</span></div>\n <div class=\"qty\">3 张</div>\n <div class=\"status-cell\">\n <span class=\"pill err\"><span class=\"dot\"></span>失败</span>\n </div>\n <div class=\"time\">2026-05-19 12:00</div>\n <div class=\"time\">2026-05-19 12:01</div>\n <div class=\"dur\">30s</div>\n <div class=\"ops\">\n <button type=\"button\">重试</button>\n <button type=\"button\">日志</button>\n </div>\n </div>\n\n <div class=\"task-row\">\n <div class=\"ph placeholder\"></div>\n <div class=\"nm\">平台套图 <span class=\"id-mono\">// T-2026-0518-0001</span></div>\n <div class=\"qty\">6 张</div>\n <div class=\"status-cell\">\n <span class=\"pill ok\"><span class=\"dot\"></span>已完成</span>\n </div>\n <div class=\"time\">2026-05-18 18:42</div>\n <div class=\"time\">2026-05-18 18:46</div>\n <div class=\"dur\">4m 10s</div>\n <div class=\"ops\">\n <button type=\"button\">查看</button>\n </div>\n </div>\n\n </div>\n\n <div class=\"pd-more\"><button type=\"button\">加载更多</button></div>\n </div>\n\n</div>\n\n<!-- 三视图 · 放大查看 lightbox -->\n<div class=\"modal-bg\" id=\"ov-tri-lightbox-bg\" onclick=\"if(event.target===this)Shell.closeModal('ov-tri-lightbox-bg')\">\n <div class=\"tri-lightbox\" role=\"dialog\" aria-label=\"三视图放大查看\">\n <button class=\"tri-lightbox-close\" type=\"button\" onclick=\"Shell.closeModal('ov-tri-lightbox-bg')\" aria-label=\"关闭\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M18 6L6 18M6 6l12 12\"/></svg>\n </button>\n <div class=\"tri-lightbox-head\">\n // 三视图(正/侧/背) · <span class=\"lb-ver\" id=\"ov-tri-lightbox-label\">v1</span>\n <span class=\"lb-tag\" id=\"ov-tri-lightbox-tag\" hidden>已采用</span>\n </div>\n <div class=\"placeholder tri-lightbox-img\" id=\"ov-tri-lightbox-img\"></div>\n <div class=\"tri-lightbox-foot\">\n <span id=\"ov-tri-lightbox-meta\">// 生成于 --:--</span>\n <span class=\"spc\"></span>\n <span><kbd>Esc</kbd> 关闭</span>\n </div>\n </div>\n</div>\n\n<script src=\"/exact/assets/icons.js?v=2026052608\"></script>\n<script src=\"/exact/assets/shell.js?v=2026060101\"></script>\n<script>\n // 从 URL ?product= 读出商品名,注入 crumb / h1 / 商品名字段\n const _urlProductName = (function () {\n try {\n const q = new URLSearchParams(location.search);\n const v = q.get('product') || q.get('name');\n return v ? decodeURIComponent(v) : '';\n } catch (e) { return ''; }\n })();\n const _productDisplayName = _urlProductName || '补水保湿精华液';\n Shell.render({\n active: 'products',\n crumbs: [\n { label: '工作台', href: 'index.html' },\n { label: '商品库', href: 'products.html' },\n { label: _productDisplayName }\n ]\n });\n if (_urlProductName) {\n const h1 = document.getElementById('pd-name');\n if (h1) h1.textContent = _urlProductName;\n const nameRow = document.querySelector('[data-field=\"name\"] .v-static');\n if (nameRow) nameRow.textContent = _urlProductName;\n const nameInput = document.querySelector('[data-field=\"name\"] .v-input');\n if (nameInput) nameInput.value = _urlProductName;\n }\n\n // 快速操作 · 跳转至对应工作台/wizard 并携带商品名\n (function bindQuickActions() {\n const productName = (document.querySelector('.pd-title h1, .pd-title, .ov-h .ti, h1') || {}).textContent || '';\n const crumbName = (document.querySelector('.crumb-current') || {}).textContent\n || (document.querySelectorAll('.crumb-item').length ? document.querySelectorAll('.crumb-item')[document.querySelectorAll('.crumb-item').length-1].textContent : '')\n || '补水保湿精华液';\n const name = (crumbName || productName || '').trim();\n document.querySelectorAll('.qa-item[data-go]').forEach(item => {\n item.style.cursor = 'pointer';\n let go = item.dataset.go;\n // 图片创作 → 独立工作台 image-optimize.html(自由创作),带 product 作为提示词种子\n let url;\n if (go === 'image-optimize') {\n url = 'image-optimize.html?t=' + Date.now() + '&prompt=' + encodeURIComponent(name);\n } else {\n url = go + '.html?t=' + Date.now() + '&product=' + encodeURIComponent(name);\n }\n item.addEventListener('click', () => { location.href = url; });\n item.addEventListener('keydown', e => {\n if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); location.href = url; }\n });\n });\n })();\n\n // 任务记录 · 「查看」按钮 / 行点击 → 跳对应工作台\n (function bindTaskRowJump() {\n const TYPE_MAP = {\n '模特上身图': 'model-photo.html',\n '平台套图': 'platform-cover.html',\n '视频素材': 'projects-new.html',\n '视频': 'projects-new.html',\n '三视图': 'model-photo.html?mode=tri',\n };\n const productName = (document.getElementById('pd-name')?.textContent || '').trim();\n document.querySelectorAll('.task-table .task-row:not(.head)').forEach(row => {\n const nameCell = row.querySelector('.nm');\n if (!nameCell) return;\n // 任务类型 = nm 的第一段文本(去掉 id-mono)\n const type = nameCell.firstChild?.nodeValue?.trim() || '';\n let url = TYPE_MAP[type];\n if (!url) return;\n url += (url.includes('?') ? '&' : '?') + 't=' + Date.now()\n + (productName ? '&product=' + encodeURIComponent(productName) : '');\n row.style.cursor = 'pointer';\n row.addEventListener('click', e => {\n if (e.target.closest('.ops button')) return;\n location.href = url;\n });\n // 「查看」按钮\n row.querySelectorAll('.ops button').forEach(btn => {\n if (btn.textContent.trim() === '查看') {\n btn.addEventListener('click', e => {\n e.stopPropagation();\n location.href = url;\n });\n }\n });\n });\n })();\n\n // 状态 pill · 三态循环(通过 → 不通过 → 归档 → 通过)\n (function bindStatusPills() {\n const labels = { pass: '通过', fail: '不通过', archive: '归档' };\n const order = ['pass', 'fail', 'archive'];\n document.addEventListener('click', e => {\n const pill = e.target.closest('.asset-card .meta .pill[data-status]');\n if (!pill) return;\n e.stopPropagation();\n if (pill.closest('.asset-card[data-tri-version]')) {\n window.ProductDetailFilters?.applyAssets?.();\n Shell.toast('三视图状态由采用版本决定', '请在三视图面板选择「采用此版本」');\n return;\n }\n const cur = pill.dataset.status;\n const next = order[(order.indexOf(cur) + 1) % order.length];\n pill.dataset.status = next;\n pill.classList.remove('pass', 'fail', 'archive');\n pill.classList.add(next);\n pill.textContent = labels[next];\n window.ProductDetailFilters?.applyAssets?.();\n Shell.toast('状态已更新', labels[next]);\n });\n })();\n\n // Tab 切换\n document.querySelectorAll('.pd-tabs .tab').forEach(t => {\n t.onclick = () => {\n document.querySelectorAll('.pd-tabs .tab').forEach(x => x.classList.remove('active'));\n t.classList.add('active');\n const target = t.dataset.tab;\n document.querySelectorAll('.tab-pane').forEach(p => {\n p.classList.toggle('active', p.dataset.pane === target);\n });\n };\n });\n\n // ─── 工具栏:筛选 / 排序 / 视图 / 加载更多(per pane)───\n (function setupToolbars() {\n // 每个 (pane, key) 的可选项 + 默认值(数组首项)\n const OPTIONS = {\n 'assets:type': ['全部类型', '模特上身图', '平台套图', '三视图'],\n // 状态:默认 通过 · 没有「全部状态」选项,只能切换 通过 / 不通过 / 归档\n 'assets:status': ['通过', '不通过', '归档'],\n 'assets:sort': ['最新生成', '最早生成'],\n 'videos:sort': ['最新导出', '最早导出'],\n 'tasks:type': ['全部类型', '模特上身图', '平台套图', '视频素材', '三视图'],\n 'tasks:status': ['全部状态', '已完成', '生成中', '排队中', '失败'],\n 'tasks:date': ['全部', '今天', '近 7 天', '近 30 天'],\n 'tasks:sort': ['提交时间倒序', '提交时间正序'],\n };\n // 「状态」永远视为已生效筛选(没有\"全部\"选项),即便选了默认值也走过滤逻辑\n const ALWAYS_APPLY_KEYS = new Set(['status']);\n // 任务行 status pill 的 class → 中文标签\n const TASK_STATUS_MAP = { ok: '已完成', gen: '生成中', wait: '排队中', err: '失败', fail: '失败' };\n // assets 卡片 data-status → 中文标签\n const ASSET_STATUS_MAP = { pass: '通过', fail: '不通过', archive: '归档' };\n\n let openPop = null;\n function closeOpenPop() {\n if (!openPop) return;\n openPop.pop.classList.remove('show');\n openPop.btn.classList.remove('open');\n openPop = null;\n }\n document.addEventListener('click', closeOpenPop);\n window.addEventListener('resize', closeOpenPop);\n // .content 区域滚动时也关\n (document.querySelector('.content') || document).addEventListener('scroll', closeOpenPop, true);\n\n document.querySelectorAll('.tab-pane').forEach(paneEl => {\n const paneId = paneEl.dataset.pane;\n\n paneEl.querySelectorAll('.pd-toolbar .filter[data-key]').forEach(btn => {\n const key = btn.dataset.key;\n const opts = OPTIONS[paneId + ':' + key];\n if (!opts) return;\n btn.dataset.value = opts[0];\n btn.dataset.default = opts[0];\n\n const pop = document.createElement('div');\n pop.className = 'filter-pop';\n pop.innerHTML = opts.map(o =>\n `<button type=\"button\" data-val=\"${o}\">${o}</button>`\n ).join('');\n document.body.appendChild(pop);\n\n btn.addEventListener('click', e => {\n e.stopPropagation();\n if (openPop && openPop.btn === btn) { closeOpenPop(); return; }\n closeOpenPop();\n const r = btn.getBoundingClientRect();\n pop.style.top = (r.bottom + 4) + 'px';\n pop.style.left = r.left + 'px';\n pop.style.minWidth = r.width + 'px';\n pop.classList.add('show');\n btn.classList.add('open');\n pop.querySelectorAll('button').forEach(b =>\n b.classList.toggle('selected', b.dataset.val === btn.dataset.value));\n openPop = { btn, pop };\n });\n pop.addEventListener('click', e => {\n e.stopPropagation();\n const opt = e.target.closest('button[data-val]');\n if (!opt) return;\n setFilterValue(btn, opt.dataset.val);\n closeOpenPop();\n applyFilters(paneEl);\n });\n });\n\n // 视图切换:网格 / 列表\n const viewTog = paneEl.querySelector('.view-tog');\n if (viewTog) {\n const btns = viewTog.querySelectorAll('button');\n btns.forEach((b, i) => {\n b.onclick = () => {\n btns.forEach(x => x.classList.remove('active'));\n b.classList.add('active');\n const grid = paneEl.querySelector('.asset-grid');\n if (grid) grid.classList.toggle('list-view', i === 1);\n };\n });\n }\n\n // 加载更多:复用前 N 个卡片克隆,演示用\n const moreBtn = paneEl.querySelector('.pd-more button');\n if (moreBtn) {\n let loaded = 0;\n moreBtn.onclick = () => {\n loaded++;\n if (loaded > 2) {\n moreBtn.textContent = '已加载全部';\n moreBtn.disabled = true;\n moreBtn.style.opacity = '.5';\n moreBtn.style.cursor = 'not-allowed';\n Shell.toast('已到末尾', '没有更多素材了');\n return;\n }\n const grid = paneEl.querySelector('.asset-grid');\n if (!grid) return;\n const src = [...grid.querySelectorAll('.asset-card')].slice(0, 4);\n src.forEach(c => grid.appendChild(c.cloneNode(true)));\n applyFilters(paneEl);\n Shell.toast('已加载更多', '+' + src.length + ' 个');\n };\n }\n\n // 首次 apply 一遍(同步 count)\n applyFilters(paneEl);\n });\n\n function setFilterValue(btn, val) {\n btn.dataset.value = val;\n [...btn.childNodes].forEach(n => { if (n.nodeType === 3) n.remove(); });\n btn.insertBefore(document.createTextNode(val + ' '), btn.firstChild);\n // 仅在切到非默认值时才高亮(状态 chip 即便永远过滤,视觉也保持中性,与「全部类型」一致)\n btn.classList.toggle('filtered', val !== btn.dataset.default);\n }\n\n function applyFilters(paneEl) {\n const paneId = paneEl.dataset.pane;\n const f = {};\n paneEl.querySelectorAll('.pd-toolbar .filter[data-key]').forEach(b => {\n f[b.dataset.key] = b.dataset.value;\n });\n const isDefault = (key) => {\n const btn = paneEl.querySelector('.pd-toolbar .filter[data-key=\"' + key + '\"]');\n if (!btn) return true; // 该 pane 没这个 filter → 等同默认,不过滤\n if (ALWAYS_APPLY_KEYS.has(key)) return false; // 状态有按钮时,永远走过滤\n return btn.dataset.value === btn.dataset.default;\n };\n\n if (paneId === 'assets' || paneId === 'videos') {\n const cards = [...paneEl.querySelectorAll('.asset-card')];\n let visible = 0;\n cards.forEach(c => {\n let show = true;\n if (paneId === 'assets' && !isDefault('type')) {\n const t = c.querySelector('.type-pill')?.textContent?.trim() || '';\n if (t !== f.type) show = false;\n }\n if (!isDefault('status')) {\n const s = c.querySelector('.pill[data-status]')?.dataset.status || '';\n if ((ASSET_STATUS_MAP[s] || '') !== f.status) show = false;\n }\n c.style.display = show ? '' : 'none';\n if (show) visible++;\n });\n if (!isDefault('sort')) {\n const grid = paneEl.querySelector('.asset-grid');\n const asc = (f.sort || '').includes('最早');\n [...grid.querySelectorAll('.asset-card')]\n .filter(c => c.style.display !== 'none')\n .sort((a, b) => {\n const ad = a.querySelector('.date')?.textContent || '';\n const bd = b.querySelector('.date')?.textContent || '';\n return asc ? ad.localeCompare(bd) : bd.localeCompare(ad);\n })\n .forEach(c => grid.appendChild(c));\n }\n updateCount(paneEl, visible);\n toggleEmpty(paneEl, visible === 0);\n // 不足 2 行时不显示「加载更多」按钮(布局后用 offsetTop 统计行数)\n const moreEl = paneEl.querySelector('.pd-more');\n if (moreEl) {\n requestAnimationFrame(() => {\n const visCards = [...paneEl.querySelectorAll('.asset-card')].filter(c => c.style.display !== 'none');\n const rows = new Set(visCards.map(c => c.offsetTop)).size;\n moreEl.style.display = rows >= 2 ? '' : 'none';\n });\n }\n } else if (paneId === 'tasks') {\n const rows = [...paneEl.querySelectorAll('.task-table .task-row:not(.head)')];\n let visible = 0;\n rows.forEach(row => {\n let show = true;\n if (!isDefault('type')) {\n const t = row.querySelector('.nm')?.firstChild?.nodeValue?.trim() || '';\n if (t !== f.type) show = false;\n }\n if (!isDefault('status')) {\n const pill = row.querySelector('.status-cell .pill');\n const cls = pill?.className || '';\n const matched = Object.entries(TASK_STATUS_MAP)\n .some(([k, v]) => cls.split(/\\s+/).includes(k) && v === f.status);\n if (!matched) show = false;\n }\n // date 过滤 · mock 数据都是 5/19,简单按天数差近似\n if (!isDefault('date')) {\n const submitTxt = row.querySelectorAll('.time')[0]?.textContent || '';\n const m = submitTxt.match(/(\\d{4})-(\\d{2})-(\\d{2})/);\n if (m) {\n const d = new Date(m[1] + '-' + m[2] + '-' + m[3]);\n const now = new Date();\n const days = Math.floor((now - d) / 86400000);\n if (f.date === '今天' && days > 0) show = false;\n if (f.date === '近 7 天' && days > 7) show = false;\n if (f.date === '近 30 天' && days > 30) show = false;\n }\n }\n row.style.display = show ? '' : 'none';\n if (show) visible++;\n });\n if (!isDefault('sort')) {\n const table = paneEl.querySelector('.task-table');\n const asc = (f.sort || '').includes('正序');\n [...table.querySelectorAll('.task-row:not(.head)')]\n .filter(r => r.style.display !== 'none')\n .sort((a, b) => {\n const ad = a.querySelectorAll('.time')[0]?.textContent || '';\n const bd = b.querySelectorAll('.time')[0]?.textContent || '';\n return asc ? ad.localeCompare(bd) : bd.localeCompare(ad);\n })\n .forEach(r => table.appendChild(r));\n }\n updateCount(paneEl, visible);\n toggleEmpty(paneEl, visible === 0);\n }\n }\n\n function updateCount(paneEl, n) {\n const ct = paneEl.querySelector('.pd-toolbar .total .ct');\n if (ct) ct.textContent = '(' + n + ')';\n }\n\n function toggleEmpty(paneEl, isEmpty) {\n let empty = paneEl.querySelector('.empty-filter');\n const container = paneEl.querySelector('.asset-grid, .task-table');\n const more = paneEl.querySelector('.pd-more');\n if (isEmpty) {\n if (!empty) {\n empty = document.createElement('div');\n empty.className = 'empty-filter';\n empty.innerHTML = '// 当前筛选下没有结果<br><a class=\"reset\">点这里重置筛选</a>';\n container?.after(empty);\n empty.querySelector('.reset').addEventListener('click', () => {\n paneEl.querySelectorAll('.pd-toolbar .filter[data-key]').forEach(b => {\n setFilterValue(b, b.dataset.default);\n });\n applyFilters(paneEl);\n });\n }\n empty.style.display = '';\n if (more) more.style.display = 'none';\n } else if (empty) {\n empty.style.display = 'none';\n if (more) more.style.display = '';\n }\n }\n\n window.ProductDetailFilters = {\n apply(pane = 'assets') {\n const paneEl = document.querySelector(`.tab-pane[data-pane=\"${pane}\"]`);\n if (paneEl) applyFilters(paneEl);\n },\n applyAssets() {\n this.apply('assets');\n }\n };\n })();\n\n // 编辑商品信息 · 在卡片内 inline 切换 view ↔ edit\n (function initEdit() {\n const card = document.getElementById('ov-main-card');\n const editBtn = document.getElementById('ov-edit-btn');\n const cancelBtn = document.getElementById('ov-cancel-btn');\n const saveBtn = document.getElementById('ov-save-btn');\n if (!card || !editBtn || !cancelBtn || !saveBtn) return;\n\n // 同时同步顶部 h1 标题 — 商品名称改动后,顶部大标题也跟着更新\n const pdName = document.getElementById('pd-name');\n\n function escapeHtml(s) {\n return s.replace(/[&<>\"']/g, c => ({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));\n }\n const X_SVG = '<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"><path d=\"M4 4l8 8M12 4l-8 8\"/></svg>';\n\n // bullet-list 编辑模式 · 操作 helpers\n function renumberBullets(list) {\n [...list.querySelectorAll('.bl-item .num')].forEach((n, i) => n.textContent = i + 1);\n }\n function bindBulletX(x, list) {\n x.addEventListener('click', () => {\n x.closest('.bl-item').remove();\n renumberBullets(list);\n });\n }\n function makeBulletLi(text) {\n const li = document.createElement('li');\n li.className = 'bl-item';\n const safe = (text || '').replace(/\"/g, '"');\n li.innerHTML = `<span class=\"num\"></span><input class=\"bl-input bl-edit\" type=\"text\" value=\"${safe}\" placeholder=\"卖点内容\"><span class=\"bl-x\" title=\"删除\">${X_SVG}</span>`;\n return li;\n }\n function addBulletItem(list, text) {\n const li = makeBulletLi(text);\n list.querySelector('.bl-add').before(li);\n bindBulletX(li.querySelector('.bl-x'), list);\n renumberBullets(list);\n }\n function renderBulletEditor(list, items) {\n list.innerHTML = '';\n // 已有项 (每条都是 input 可直接修改)\n items.forEach(text => list.appendChild(makeBulletLi(text)));\n // 添加行\n const addLi = document.createElement('li');\n addLi.className = 'bl-add';\n addLi.innerHTML = `<span class=\"num\">+</span><input class=\"bl-input\" placeholder=\"添加新卖点 · 回车确认\">`;\n list.appendChild(addLi);\n // 绑定已有项的删除\n list.querySelectorAll('.bl-item .bl-x').forEach(x => bindBulletX(x, list));\n // 绑定回车追加\n const addInput = addLi.querySelector('.bl-input');\n addInput.addEventListener('keydown', e => {\n if (e.key === 'Enter') {\n e.preventDefault();\n const v = addInput.value.trim();\n if (!v) return;\n addBulletItem(list, v);\n addInput.value = '';\n addInput.focus();\n }\n });\n renumberBullets(list);\n }\n\n // 进入编辑模式: 把当前静态值灌进 input\n function enterEdit() {\n card.querySelectorAll('[data-field]').forEach(row => {\n const stat = row.querySelector('.v-static');\n const inp = row.querySelector('.v-edit');\n if (!stat || !inp) return;\n if (row.dataset.field === 'bullets') {\n const items = [...stat.querySelectorAll('.bullet')].map(b => b.textContent.trim());\n renderBulletEditor(inp, items);\n } else if (inp.tagName === 'SELECT') {\n const cur = stat.textContent.trim();\n [...inp.options].forEach((o, i) => { if (o.textContent.trim() === cur) inp.selectedIndex = i; });\n } else {\n inp.value = stat.textContent.trim();\n }\n });\n card.classList.add('editing');\n }\n function exitEdit() {\n card.classList.remove('editing');\n }\n function save() {\n card.querySelectorAll('[data-field]').forEach(row => {\n const stat = row.querySelector('.v-static');\n const inp = row.querySelector('.v-edit');\n if (!stat || !inp) return;\n if (row.dataset.field === 'bullets') {\n // 从 .bl-item .bl-edit input 读值 (允许修改已有条目)\n const items = [...inp.querySelectorAll('.bl-item .bl-edit')]\n .map(t => t.value.trim()).filter(Boolean);\n stat.innerHTML = items.map(s => `<span class=\"bullet\">${escapeHtml(s)}</span>`).join('');\n } else {\n const val = (inp.tagName === 'SELECT') ? inp.options[inp.selectedIndex].textContent : inp.value;\n stat.textContent = val.trim();\n if (row.dataset.field === 'name' && pdName) {\n pdName.textContent = val.trim() || pdName.textContent;\n }\n }\n });\n card.classList.remove('editing');\n Shell.toast('已保存', '商品信息已更新');\n }\n // 重置 · 在编辑模式下,把所有输入框回退到当前静态值 (相当于重新进入 edit)\n function resetEdit() {\n card.querySelectorAll('[data-field]').forEach(row => {\n const stat = row.querySelector('.v-static');\n const inp = row.querySelector('.v-edit');\n if (!stat || !inp) return;\n if (row.dataset.field === 'bullets') {\n const items = [...stat.querySelectorAll('.bullet')].map(b => b.textContent.trim());\n renderBulletEditor(inp, items);\n } else if (inp.tagName === 'SELECT') {\n const cur = stat.textContent.trim();\n [...inp.options].forEach((o, i) => { if (o.textContent.trim() === cur) inp.selectedIndex = i; });\n } else {\n inp.value = stat.textContent.trim();\n }\n });\n Shell.toast('已重置');\n }\n\n const resetBtn = document.getElementById('ov-reset-btn');\n // 防御性: 先清空可能存在的 inline onclick, 再用 addEventListener 绑定\n editBtn.onclick = null;\n cancelBtn.onclick = null;\n saveBtn.onclick = null;\n if (resetBtn) resetBtn.onclick = null;\n editBtn.addEventListener('click', (e) => { e.preventDefault(); enterEdit(); });\n cancelBtn.addEventListener('click', (e) => { e.preventDefault(); exitEdit(); });\n saveBtn.addEventListener('click', (e) => { e.preventDefault(); save(); });\n if (resetBtn) resetBtn.addEventListener('click', (e) => { e.preventDefault(); resetEdit(); });\n\n // 编辑模式下,点缩略图 → 删除\n const grid = document.getElementById('ov-images-grid');\n if (grid) {\n grid.addEventListener('click', e => {\n if (!card.classList.contains('editing')) return;\n const thumb = e.target.closest('.thumb');\n if (thumb && !thumb.classList.contains('img-upload')) {\n thumb.remove();\n }\n });\n // [+] 上传占位\n const addBtn = document.getElementById('ov-img-add');\n if (addBtn) addBtn.onclick = () => Shell.toast('上传图片', '请选择本地图片 (占位)');\n }\n })();\n\n // AI 生成三视图 · 按钮悬浮 panel(可重复打开 / X 关闭 / 点击外部关闭 / Esc 关闭)\n (function initTriView() {\n const btn = document.getElementById('ov-tri-btn');\n const pop = document.getElementById('ov-tri-pop');\n const closeBtn = document.getElementById('ov-tri-close');\n const startBtn = document.getElementById('ov-tri-start');\n const img = document.getElementById('ov-tri-img');\n const statusEl = document.getElementById('ov-tri-status');\n const foot = document.getElementById('ov-tri-foot');\n const history = document.getElementById('ov-tri-history');\n const historyRow = document.getElementById('ov-tri-history-row');\n const historyCount = document.getElementById('ov-tri-history-count');\n if (!btn || !pop || !closeBtn || !startBtn) return;\n\n const versions = []; // [{ ts, label }]\n let previewIdx = -1; // 主图当前正在「预览」哪一版(浏览态)\n let adoptedIdx = -1; // 真正被「采用」的那一版 · 与素材库通过状态联动\n let generating = false;\n\n function prodName() {\n return (document.getElementById('pd-name')?.textContent || '商品').trim();\n }\n\n function refreshAssetFilters() {\n window.ProductDetailFilters?.applyAssets?.();\n }\n\n function open() {\n pop.classList.add('show');\n btn.classList.add('is-open');\n btn.setAttribute('aria-expanded', 'true');\n }\n function close() {\n pop.classList.remove('show');\n btn.classList.remove('is-open');\n btn.setAttribute('aria-expanded', 'false');\n }\n function toggle() {\n if (pop.classList.contains('show')) close(); else open();\n }\n\n function renderHistory() {\n if (versions.length === 0) { history.classList.remove('show'); return; }\n history.classList.add('show');\n historyCount.textContent = versions.length;\n historyRow.innerHTML = versions.map((ver, i) => {\n const isAdopted = i === adoptedIdx;\n const isPreview = i === previewIdx;\n const cls = [\n isAdopted ? 'adopted' : '',\n isPreview && !isAdopted ? 'previewing' : '',\n ].filter(Boolean).join(' ');\n const titleParts = [ver.label, ver.ts];\n if (isAdopted) titleParts.push('已采用');\n else if (isPreview) titleParts.push('预览中');\n return `\n <div class=\"h-thumb ${cls}\" data-idx=\"${i}\" title=\"${titleParts.join(' · ')}\">\n <span class=\"badge\">已采用</span>\n <span class=\"v\">${ver.label}</span>\n </div>\n `;\n }).join('');\n historyRow.querySelectorAll('.h-thumb').forEach(el => {\n el.addEventListener('click', () => {\n const idx = Number(el.dataset.idx);\n if (idx === previewIdx) return;\n setPreview(idx);\n });\n });\n }\n\n function renderMain() {\n if (previewIdx < 0) return;\n const ver = versions[previewIdx];\n const isAdopted = previewIdx === adoptedIdx;\n img.innerHTML = `<span class=\"ph-frame\">${prodName()} · 三视图(正/侧/背) · ${ver.label}</span>`;\n img.classList.add('is-zoomable');\n img.title = '点击放大查看';\n statusEl.textContent = isAdopted\n ? `${ver.label} · 已采用,不满意可重跑`\n : `${ver.label} · 预览中(未采用)`;\n foot.innerHTML = `\n <button class=\"ov-edit\" type=\"button\" id=\"ov-tri-rerun\" style=\"height:28px;\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 12a9 9 0 1 0 3-6.7\"/><path d=\"M3 4v5h5\"/></svg>\n 重跑\n </button>\n <button class=\"ov-edit ${isAdopted ? '' : 'primary'}\" type=\"button\" id=\"ov-tri-adopt\" style=\"height:28px;\" ${isAdopted ? 'disabled title=\"此版本已采用\"' : 'title=\"将此版本设为唯一通过版本,其他版本变为不通过\"'}>\n ${isAdopted\n ? '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 12l5 5L20 6\"/></svg> 已采用'\n : '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 12l5 5L20 6\"/></svg> 采用此版本'}\n </button>\n <span style=\"flex:1;\"></span>\n <span class=\"mono\" style=\"font-size:11px; color: var(--black-alpha-56);\">~¥0.30 / 次</span>\n `;\n document.getElementById('ov-tri-rerun')?.addEventListener('click', start);\n document.getElementById('ov-tri-adopt')?.addEventListener('click', adoptPreview);\n }\n\n function syncLibraryStatus() {\n const grid = document.querySelector('.tab-pane[data-pane=\"assets\"] .asset-grid');\n if (!grid) return;\n const adoptedLabel = versions[adoptedIdx]?.label;\n grid.querySelectorAll('.asset-card[data-tri-version]').forEach(c => {\n const pill = c.querySelector('.pill');\n if (!pill) return;\n const isAdopted = c.dataset.triVersion === adoptedLabel;\n pill.className = 'pill ' + (isAdopted ? 'pass' : 'fail');\n pill.textContent = isAdopted ? '通过' : '不通过';\n pill.setAttribute('data-status', isAdopted ? 'pass' : 'fail');\n pill.setAttribute('title', isAdopted ? '当前采用版本' : '未被采用');\n });\n refreshAssetFilters();\n }\n\n function appendLibraryCard(ver) {\n const grid = document.querySelector('.tab-pane[data-pane=\"assets\"] .asset-grid');\n if (!grid) return;\n const now = new Date();\n const pad = n => String(n).padStart(2, '0');\n const dateStr = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`;\n const card = document.createElement('div');\n card.className = 'asset-card';\n card.dataset.triVersion = ver.label;\n // 新生成的版本默认 `不通过`,等用户点「采用此版本」才转为通过\n card.innerHTML = `\n <div class=\"thumb placeholder\"><span class=\"type-pill\">三视图</span><span class=\"ph-frame\">${prodName()} · ${ver.label}</span></div>\n <div class=\"meta\"><span class=\"pill fail\" data-status=\"fail\" title=\"未被采用\">不通过</span><span class=\"date\">${dateStr}</span></div>\n `;\n grid.prepend(card);\n // 更新「全部 AI 素材 (N)」计数\n const ct = document.querySelector('.pd-toolbar .total .ct');\n if (ct) {\n const m = ct.textContent.match(/(\\d+)/);\n const n = m ? Number(m[1]) + 1 : 1;\n ct.textContent = `(${n})`;\n }\n refreshAssetFilters();\n }\n\n // 切换主图预览(不动采用状态、不动素材库)\n function setPreview(idx) {\n previewIdx = idx;\n renderHistory();\n renderMain();\n }\n\n // 显式「采用」当前预览版本 · 同步素材库通过/不通过\n function adoptPreview() {\n if (previewIdx < 0) return;\n if (previewIdx === adoptedIdx) return;\n adoptedIdx = previewIdx;\n renderHistory();\n renderMain();\n syncLibraryStatus();\n if (window.Shell?.toast) {\n Shell.toast('已采用 ' + versions[adoptedIdx].label,\n prodName() + ' · 该版本通过,其余版本转为不通过');\n }\n }\n\n function renderLoading() {\n img.innerHTML = `<div style=\"display:flex;flex-direction:column;gap:6px;align-items:center;\"><div class=\"spinner\"></div><span class=\"ph-frame\" style=\"font-size:10.5px;\">生成中</span></div>`;\n img.classList.remove('is-zoomable');\n img.removeAttribute('title');\n statusEl.textContent = '生成中 · 约 12s';\n foot.innerHTML = '<span class=\"mono\" style=\"font-size:11px; color: var(--black-alpha-48);\">// POST /assets/tri-view</span>';\n }\n\n function openLightbox() {\n if (previewIdx < 0) return;\n const ver = versions[previewIdx];\n const isAdopted = previewIdx === adoptedIdx;\n const lbImg = document.getElementById('ov-tri-lightbox-img');\n const lbLabel = document.getElementById('ov-tri-lightbox-label');\n const lbTag = document.getElementById('ov-tri-lightbox-tag');\n const lbMeta = document.getElementById('ov-tri-lightbox-meta');\n if (lbImg) lbImg.innerHTML = `<span class=\"ph-frame\">${prodName()} · 三视图(正/侧/背) · ${ver.label}</span>`;\n if (lbLabel) lbLabel.textContent = ver.label;\n if (lbTag) {\n lbTag.hidden = !isAdopted;\n lbTag.textContent = '已采用';\n }\n if (lbMeta) lbMeta.textContent = `// 生成于 ${ver.ts}`;\n window.Shell?.openModal?.('ov-tri-lightbox-bg');\n }\n\n function start() {\n if (generating) return;\n generating = true;\n open();\n renderLoading();\n setTimeout(() => {\n generating = false;\n const now = new Date();\n const ts = String(now.getHours()).padStart(2,'0') + ':' + String(now.getMinutes()).padStart(2,'0');\n const newVer = { ts, label: 'v' + (versions.length + 1) };\n versions.push(newVer);\n appendLibraryCard(newVer);\n const newIdx = versions.length - 1;\n previewIdx = newIdx;\n // 第一次生成 · 自动采用新版本(无选择可言);之后只切预览,不动采用\n if (adoptedIdx === -1) {\n adoptedIdx = newIdx;\n syncLibraryStatus();\n }\n renderHistory();\n renderMain();\n if (window.Shell?.toast) {\n const tip = (adoptedIdx === newIdx)\n ? `${newVer.label} · 已采用并同步到素材库`\n : `${newVer.label} · 预览中 · 满意请点「采用此版本」`;\n Shell.toast('三视图已生成', `${prodName()} · ${tip}`);\n }\n }, 1800);\n }\n\n btn.addEventListener('click', (e) => { e.stopPropagation(); toggle(); });\n closeBtn.addEventListener('click', (e) => { e.stopPropagation(); close(); });\n startBtn.addEventListener('click', (e) => { e.stopPropagation(); start(); });\n pop.addEventListener('click', (e) => e.stopPropagation());\n img.addEventListener('click', (e) => {\n if (!img.classList.contains('is-zoomable')) return;\n e.stopPropagation();\n openLightbox();\n });\n document.addEventListener('click', (e) => {\n if (!pop.classList.contains('show')) return;\n if (pop.contains(e.target) || btn.contains(e.target)) return;\n close();\n });\n document.addEventListener('keydown', (e) => {\n if (e.key === 'Escape' && pop.classList.contains('show')) close();\n });\n })();\n\n // 从 create 跳来时显示 toast + 清空三个 tab 数据(新商品没有素材/项目/任务)\n if (location.search.includes('id=new')) {\n setTimeout(() => Shell.toast('商品已创建', '开始创建 AI 资产'), 200);\n\n // 从 sessionStorage 读出 drawer 刚保存的完整 product,注入到「商品信息」卡\n (function injectFromSession() {\n let p = null;\n try {\n const raw = sessionStorage.getItem('npd-last-created');\n if (raw) p = JSON.parse(raw);\n sessionStorage.removeItem('npd-last-created'); // 读完即清,避免污染\n } catch (e) { /* ignore */ }\n if (!p) return;\n\n const esc = s => String(s == null ? '' : s).replace(/[&<>\"']/g, c => ({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));\n\n // 商品名称(URL 已经设过,这里兜底)\n if (p.name) {\n const nm = document.querySelector('[data-field=\"name\"] .v-static');\n if (nm) nm.textContent = p.name;\n const nmI = document.querySelector('[data-field=\"name\"] .v-input');\n if (nmI) nmI.value = p.name;\n }\n // 品类\n if (p.cat) {\n const catRow = document.querySelector('[data-field=\"cat\"] .v-static');\n if (catRow) catRow.textContent = p.cat;\n const catSel = document.querySelector('[data-field=\"cat\"] .v-select');\n if (catSel) {\n // 找到匹配 option 并 select;若没有则插入到首位\n let matched = false;\n [...catSel.options].forEach(o => { if (o.value === p.cat || o.textContent === p.cat) { o.selected = true; matched = true; } });\n if (!matched) {\n const opt = document.createElement('option');\n opt.value = p.cat; opt.textContent = p.cat; opt.selected = true;\n catSel.insertBefore(opt, catSel.firstChild);\n }\n }\n }\n // 目标人群(可空)\n const tgtRow = document.querySelector('[data-field=\"target\"] .v-static');\n const tgtIn = document.querySelector('[data-field=\"target\"] .v-input');\n if (tgtRow) tgtRow.textContent = p.target || '—';\n if (tgtIn) tgtIn.value = p.target || '';\n // 卖点\n if (Array.isArray(p.points)) {\n const blStatic = document.querySelector('[data-field=\"bullets\"] .v-static');\n if (blStatic) {\n blStatic.innerHTML = p.points.length\n ? p.points.map(t => `<span class=\"bullet\">${esc(t)}</span>`).join('')\n : '<span class=\"bullet\" style=\"color:var(--black-alpha-48)\">—</span>';\n }\n }\n // 商品图片 — 替换 6 张占位为真实 dataUrl;数量按上传数定\n const grid = document.getElementById('ov-images-grid');\n const addBtn = document.getElementById('ov-img-add');\n if (grid) {\n // 移除现有所有 .thumb 占位(保留末尾 #ov-img-add)\n [...grid.querySelectorAll('.thumb')].forEach(t => t.remove());\n if (Array.isArray(p.images) && p.images.length) {\n p.images.forEach(img => {\n const t = document.createElement('div');\n t.className = 'thumb';\n t.style.cssText = 'background-image:url(' + img.dataUrl + ');background-size:cover;background-position:center;';\n if (addBtn) grid.insertBefore(t, addBtn);\n else grid.appendChild(t);\n });\n }\n // 同步计数\n const ct = document.querySelector('.ov-images-sub .sub-h .ct');\n if (ct) ct.textContent = '(' + ((p.images || []).length) + ')';\n }\n })();\n\n const EMPTY_HTML = (txt) => `<div class=\"empty-filter\">// NO DATA<br><span style=\"margin-top:6px;display:inline-block\">${txt}</span></div>`;\n\n // AI 生成素材\n const assetsPane = document.querySelector('.tab-pane[data-pane=\"assets\"]');\n if (assetsPane) {\n const grid = assetsPane.querySelector('.asset-grid');\n if (grid) grid.outerHTML = EMPTY_HTML('还没有 AI 素材,使用右上角「图片生成」开始创建');\n const ct = assetsPane.querySelector('.total .ct');\n if (ct) ct.textContent = '(0)';\n const more = assetsPane.querySelector('.pd-more');\n if (more) more.remove();\n }\n\n // 视频项目\n const videosPane = document.querySelector('.tab-pane[data-pane=\"videos\"]');\n if (videosPane) {\n const grid = videosPane.querySelector('.asset-grid');\n if (grid) grid.outerHTML = EMPTY_HTML('还没有视频项目,前往工作台「新建项目」开始');\n const ct = videosPane.querySelector('.total .ct');\n if (ct) ct.textContent = '(0)';\n const more = videosPane.querySelector('.pd-more');\n if (more) more.remove();\n }\n\n // 任务记录\n const tasksPane = document.querySelector('.tab-pane[data-pane=\"tasks\"]');\n if (tasksPane) {\n tasksPane.querySelectorAll('.task-stat .v').forEach(el => {\n const small = el.querySelector('small');\n el.textContent = '0';\n if (small) el.appendChild(small);\n });\n const tbl = tasksPane.querySelector('.task-table');\n if (tbl) tbl.outerHTML = EMPTY_HTML('暂无任务记录');\n const ct = tasksPane.querySelector('.total .ct');\n if (ct) ct.textContent = '(0)';\n const more = tasksPane.querySelector('.pd-more');\n if (more) more.remove();\n }\n }\n</script>\n<script src=\"/exact/assets/api-bridge.js?v=2026060114\"></script>\n</body>\n</html>\n",
|
||
"products": "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta name=\"x-airshelf-exact-source\" content=\"products.html\">\n<meta charset=\"utf-8\">\n<title>商品库 · Airshelf</title>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"/exact/assets/restraint.css?v=2026060101\">\n<style>\n /* ─── 全局 viewport 高度链 (让右侧 toolbar/分页吸顶吸底) ─── */\n /* 整页滚动 · 头部 H1+actions sticky 固定 · 其他随页面滚 */\n .page-head {\n position: sticky;\n top: 0;\n z-index: 5;\n background: var(--background-base);\n padding-top: 4px;\n margin-top: -4px;\n }\n\n /* ─── 主区 (普通文档流) ─── */\n .products-main { display: flex; flex-direction: column; }\n .products-main .result-meta {\n margin-bottom: 12px;\n display: flex;\n align-items: center;\n gap: 10px;\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--black-alpha-48);\n letter-spacing: .02em;\n }\n\n /* ─── 商品分类多选 chip · menu 内多选项 + 计数 + 全选/全清 ─── */\n .chip-wrap[data-key=\"cat\"] .chip-menu {\n min-width: 220px;\n padding: 4px;\n }\n .chip-wrap[data-key=\"cat\"] .mi-all {\n border-bottom: 1px solid var(--border-faint);\n margin-bottom: 4px;\n padding-bottom: 4px;\n }\n .chip-wrap[data-key=\"cat\"] .mi .cat-count {\n margin-left: auto;\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n }\n .chip-wrap[data-key=\"cat\"] .mi.selected .cat-count { color: var(--heat); }\n .chip .chip-count {\n display: inline-flex; align-items: center; justify-content: center;\n height: 18px; min-width: 18px; padding: 0 5px;\n background: var(--heat-12); color: var(--heat);\n border: 1px solid var(--heat-20);\n border-radius: var(--r-pill);\n font-family: var(--font-mono); font-size: 10.5px; font-weight: 600;\n letter-spacing: .02em;\n margin-left: 2px;\n }\n .product-grid-wrap {\n /* 滚动区与外栏视觉对齐 */\n margin: 0 -8px;\n padding: 2px 8px 24px;\n }\n\n /* ─── 生成类型选择 modal ─── */\n .gen-choice-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }\n .gen-choice-card {\n display: flex; flex-direction: column; gap: 10px;\n padding: 18px 16px;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n cursor: pointer;\n text-align: left;\n font-family: inherit;\n transition: background var(--t-base), border-color var(--t-base), transform var(--t-base);\n }\n .gen-choice-card:hover {\n border-color: var(--heat);\n background: var(--surface);\n transform: translateY(-1px);\n }\n .gen-choice-card .gc-ic {\n width: 36px; height: 36px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n display: grid; place-items: center;\n color: var(--black-alpha-72);\n }\n .gen-choice-card:hover .gc-ic { color: var(--heat); border-color: var(--heat-20); background: var(--heat-12); }\n .gen-choice-card .gc-ic svg { width: 18px; height: 18px; }\n .gen-choice-card .gc-t { font-size: 14px; font-weight: 600; color: var(--accent-black); }\n .gen-choice-card .gc-d { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; line-height: 1.45; }\n\n .product-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));\n gap: 16px;\n }\n .product-card {\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n cursor: pointer;\n transition: background .15s, border-color .15s;\n position: relative;\n overflow: hidden;\n display: flex; flex-direction: column;\n }\n .product-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }\n .product-thumb { aspect-ratio: 1.4 / 1; }\n\n .product-body {\n padding: 14px 14px 12px;\n flex: 1;\n }\n .product-name {\n font-size: 14px; font-weight: 600;\n color: var(--accent-black);\n line-height: 1.3;\n overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n }\n .product-cat {\n display: inline-flex; align-items: center;\n margin-top: 8px;\n padding: 2px 8px;\n background: var(--background-lighter);\n color: var(--black-alpha-72);\n border-radius: var(--r-sm);\n font-size: 11.5px;\n }\n .product-date {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--black-alpha-48);\n margin-top: 10px;\n letter-spacing: .02em;\n }\n\n /* ─── 卡片底栏 · V2.1 克制版 (线图标 + mono 文本) ─── */\n .product-footer {\n display: grid;\n grid-template-columns: 1fr auto 1fr;\n align-items: center;\n column-gap: 8px;\n padding: 10px 12px;\n border-top: 1px solid var(--border-faint);\n font-size: 11.5px;\n color: #6f6f6f;\n background: var(--background-base);\n }\n .product-footer .stat {\n display: inline-flex; align-items: center; justify-content: center; gap: 5px;\n padding: 3px 8px;\n border-radius: var(--r-sm);\n font-family: var(--font-mono);\n letter-spacing: .02em;\n white-space: nowrap; /* 防止\"素材\"等中文被挤压成竖排 */\n border: 1px solid transparent;\n transition: background var(--t-base), color var(--t-base), border-color var(--t-base);\n }\n .product-footer .stat { justify-self: center; }\n .product-footer .stat[data-type] { cursor: pointer; }\n .product-footer .stat[data-type]:hover {\n background: var(--heat-12);\n color: var(--heat);\n border-color: var(--heat-20);\n }\n .product-footer .stat[data-type]:hover svg { color: var(--heat); }\n .product-footer .stat[data-type]:hover b { color: var(--heat); }\n .product-footer .stat svg {\n width: 14px; height: 14px;\n color: currentColor;\n flex-shrink: 0;\n stroke-width: 1.25;\n transition: color var(--t-base);\n }\n .product-footer .stat b {\n color: var(--accent-black);\n font-weight: 600;\n transition: color var(--t-base);\n }\n /* default ↔ hover 文本切换 */\n .product-footer .stat .stat-hover { display: none; }\n .product-footer .stat[data-type]:hover > svg { display: none; }\n .product-footer .stat[data-type]:hover .stat-default { display: none; }\n .product-footer .stat[data-type]:hover .stat-hover { display: inline; }\n .product-footer .sep {\n color: #b8b8b8;\n font-family: var(--font-mono);\n flex-shrink: 0;\n }\n\n /* ─── 底部分页 (吸底) ─── */\n .pagination {\n flex-shrink: 0;\n display: flex; align-items: center; gap: 16px;\n padding: 14px 0;\n border-top: 1px solid var(--border-faint);\n background: var(--background-base);\n font-size: 12.5px;\n color: var(--black-alpha-56);\n }\n .pagination[hidden] { display: none; }\n .pagination .page-size { transition: border-color var(--t-base), color var(--t-base); }\n .pagination .page-size:hover { border-color: var(--black-alpha-32); color: var(--accent-black); }\n .pagination .pages .ellipsis {\n min-width: 22px; height: 30px;\n display: inline-flex; align-items: center; justify-content: center;\n color: var(--black-alpha-48);\n font-family: var(--font-mono);\n }\n .pagination .total { font-family: var(--font-mono); letter-spacing: .02em; }\n .pagination .page-size {\n display: inline-flex; align-items: center; gap: 4px;\n height: 30px;\n padding: 0 10px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n cursor: pointer;\n font-family: inherit;\n font-size: 12.5px;\n color: var(--black-alpha-72);\n }\n .pagination .page-size svg { width: 10px; height: 10px; opacity: .6; }\n .pagination .pages {\n display: inline-flex; gap: 4px;\n margin-left: auto;\n }\n .pagination .pages button {\n min-width: 30px; height: 30px;\n padding: 0 8px;\n border: 1px solid var(--border-faint);\n background: var(--surface);\n border-radius: var(--r-sm);\n cursor: pointer;\n font-size: 12.5px;\n color: var(--black-alpha-72);\n font-family: inherit;\n transition: background var(--t-base), border-color var(--t-base), color var(--t-base);\n }\n .pagination .pages button:hover:not(.active):not(:disabled) { border-color: var(--black-alpha-32); color: var(--accent-black); }\n .pagination .pages button.active {\n background: var(--heat);\n color: var(--accent-white);\n border-color: var(--heat);\n font-weight: 600;\n }\n .pagination .pages button:disabled { opacity: .4; cursor: not-allowed; }\n .pagination .jump {\n display: inline-flex; align-items: center; gap: 6px;\n color: var(--black-alpha-56);\n }\n .pagination .jump input {\n width: 44px; height: 30px;\n border: 1px solid var(--border-faint);\n background: var(--surface);\n border-radius: var(--r-sm);\n text-align: center;\n font-size: 12.5px;\n color: var(--accent-black);\n font-family: inherit;\n outline: none;\n transition: border-color var(--t-base);\n }\n .pagination .jump input:focus { border-color: var(--heat-40); }\n\n /* 响应式 */\n @media (max-width: 900px) {\n .toolbar .search-inline { max-width: 100%; flex-basis: 100%; }\n }\n\n /* ============================================================\n 新建商品 drawer · 复用 .drawer (restraint.css)\n 在 products.html 上原地打开, 后面保留商品网格作为上下文\n ============================================================ */\n .pc-drawer { width: 820px; max-width: 100vw; }\n .pc-drawer .drawer-h h3 { font-size: 16px; font-weight: 600; }\n .pc-drawer .drawer-b { padding: 24px 28px; }\n .pc-drawer .drawer-b .form-card {\n background: transparent; border: 0; padding: 0; border-radius: 0;\n }\n .pc-drawer .drawer-f {\n padding: 14px 24px; background: var(--surface); align-items: center;\n }\n .pc-drawer .drawer-f .btn-guide {\n margin-right: auto;\n display: inline-flex; align-items: center; gap: 6px;\n font-size: 13px; color: var(--black-alpha-56);\n background: transparent; border: 0; cursor: pointer;\n padding: 8px 10px; border-radius: var(--r-md);\n font-family: inherit;\n transition: background var(--t-base), color var(--t-base);\n }\n .pc-drawer .drawer-f .btn-guide:hover { color: var(--accent-black); background: var(--black-alpha-4); }\n .pc-drawer .drawer-f .btn-guide svg { width: 14px; height: 14px; }\n\n /* form-card · 表单容器(drawer 内被去外观,直接铺) */\n .form-card .form-h {\n font-size: 15px; font-weight: 600; color: var(--accent-black);\n margin-bottom: 18px; padding-bottom: 12px;\n border-bottom: 1px solid var(--border-faint);\n }\n .form-card .field { margin-bottom: 16px; }\n .form-card .field:last-child { margin-bottom: 0; }\n .form-card .field-row {\n display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 16px;\n }\n .form-card .field-label {\n display: block; font-size: 13px; font-weight: 500;\n color: var(--accent-black); margin-bottom: 6px;\n }\n .form-card .field-label .req { color: var(--heat); margin-left: 2px; }\n .form-card .field-label .opt {\n color: var(--black-alpha-48); font-weight: 400; font-size: 12px; margin-left: 6px;\n }\n .form-card .input,\n .form-card .select {\n width: 100%; height: 38px;\n background: var(--background-lighter);\n border: 1px solid var(--black-alpha-12);\n border-radius: var(--r-md);\n padding: 0 14px;\n font-size: 13.5px; color: var(--accent-black);\n outline: none; font-family: inherit;\n transition: border-color var(--t-base);\n }\n .form-card .input:focus,\n .form-card .select:focus {\n border-color: var(--heat-40);\n box-shadow: inset 0 0 0 1px var(--heat-40);\n }\n\n /* 商品主图 · 上传(左) + 示例(右) */\n .form-card .pf-upload-row {\n display: grid;\n grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);\n gap: 16px; align-items: stretch;\n }\n .form-card .pf-upload-zone {\n border: 1.5px dashed var(--black-alpha-24);\n border-radius: var(--r-md);\n padding: 28px 20px;\n background: var(--background-lighter);\n cursor: pointer; text-align: center;\n transition: border-color var(--t-base), background var(--t-base);\n display: flex; flex-direction: column; align-items: center; justify-content: center;\n min-height: 180px;\n }\n .form-card .pf-upload-zone:hover { border-color: var(--heat); background: var(--heat-8); }\n .form-card .pf-upload-zone .uz-ic {\n width: 44px; height: 44px;\n margin: 0 auto 10px;\n background: var(--surface);\n border: 1px solid var(--heat-20);\n border-radius: var(--r-md);\n color: var(--heat);\n display: grid; place-items: center;\n }\n .form-card .pf-upload-zone .uz-ic svg { width: 20px; height: 20px; }\n .form-card .pf-upload-zone .uz-t { font-size: 14px; color: var(--accent-black); font-weight: 500; }\n .form-card .pf-upload-zone .uz-t strong { color: var(--heat); font-weight: 600; }\n .form-card .pf-upload-zone .uz-d {\n margin-top: 8px;\n font-family: var(--font-mono); font-size: 11.5px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n }\n /* 示例图 · 纵向卡片 */\n .form-card .pf-example {\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n padding: 16px;\n display: flex; flex-direction: column; gap: 10px;\n }\n .form-card .pf-example .ex-h {\n font-size: 13px; font-weight: 600; color: var(--accent-black);\n }\n .form-card .pf-example .ex-grid {\n display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;\n }\n .form-card .pf-example .ex-grid .ex-thumb {\n aspect-ratio: 1;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n overflow: hidden; position: relative;\n display: grid; place-items: center;\n color: var(--black-alpha-32);\n }\n .form-card .pf-example .ex-grid .ex-thumb svg { width: 22px; height: 22px; }\n .form-card .pf-example .ex-grid .ex-thumb::after {\n content: ''; position: absolute; inset: 0;\n background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);\n pointer-events: none;\n }\n .form-card .pf-example .ex-d {\n font-size: 12px; color: var(--black-alpha-56); line-height: 1.5;\n }\n\n .form-card .pf-grid {\n display: grid; grid-template-columns: repeat(5, 1fr);\n gap: 8px; margin-top: 12px;\n }\n .form-card .pf-grid:empty { display: none; }\n .form-card .pf-thumb {\n aspect-ratio: 1;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n position: relative; overflow: hidden; cursor: pointer;\n }\n .form-card .pf-thumb img { width: 100%; height: 100%; object-fit: cover; }\n .form-card .pf-thumb .pf-x {\n position: absolute; top: 4px; right: 4px;\n width: 22px; height: 22px;\n background: rgba(0,0,0,.7); color: var(--accent-white);\n border: 0; border-radius: 50%; cursor: pointer;\n display: grid; place-items: center;\n opacity: 0; transition: opacity var(--t-base);\n }\n .form-card .pf-thumb:hover .pf-x { opacity: 1; }\n .form-card .pf-thumb .pf-x svg { width: 11px; height: 11px; }\n\n /* 核心卖点 · bullet-list */\n .form-card .bullet-list { list-style: none; padding: 0; margin: 0; }\n .form-card .bullet-list .bl-item,\n .form-card .bullet-list .bl-add {\n display: flex; align-items: center; gap: 10px;\n padding: 8px 12px;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n margin-bottom: 6px;\n font-size: 13.5px;\n }\n .form-card .bullet-list .bl-add { background: transparent; border-style: dashed; }\n .form-card .bullet-list .num {\n width: 22px; height: 22px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n font-family: var(--font-mono);\n font-size: 11px; color: var(--heat); font-weight: 700;\n display: grid; place-items: center; flex-shrink: 0;\n }\n .form-card .bullet-list .bl-text { flex: 1; color: var(--accent-black); }\n .form-card .bullet-list .bl-input {\n flex: 1; background: transparent; border: 0; outline: none;\n font-size: 13.5px; color: var(--accent-black); font-family: inherit;\n }\n .form-card .bullet-list .bl-x {\n width: 22px; height: 22px;\n color: var(--black-alpha-48);\n cursor: pointer; display: grid; place-items: center;\n border-radius: var(--r-sm);\n transition: color var(--t-base), background var(--t-base);\n }\n .form-card .bullet-list .bl-x:hover { color: var(--accent-crimson); background: var(--crimson-bg); }\n .form-card .bullet-list .bl-x svg { width: 11px; height: 11px; }\n @media (max-width: 900px) {\n .pc-drawer .drawer-b .pf-upload-row { grid-template-columns: 1fr; }\n }\n\n /* ============================================================\n 视图切换 · 网格 / 列表\n ============================================================ */\n .view-tog {\n display: inline-flex;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n padding: 2px;\n flex-shrink: 0;\n }\n .view-tog button {\n width: 30px; height: 28px;\n display: grid; place-items: center;\n border: 0;\n background: transparent;\n color: var(--black-alpha-48);\n cursor: pointer;\n border-radius: 4px;\n transition: background var(--t-base), color var(--t-base);\n }\n .view-tog button:hover { color: var(--accent-black); }\n .view-tog button.active {\n background: var(--accent-black);\n color: var(--accent-white);\n }\n .view-tog button svg { width: 13px; height: 13px; }\n\n /* 列表视图: 把网格改为单列, 每行横向布局 */\n .product-grid.list-view { display: flex; flex-direction: column; gap: 8px; }\n .product-grid.list-view .product-card {\n display: grid;\n grid-template-columns: 100px 1fr auto;\n gap: 16px;\n padding: 12px 16px;\n align-items: center;\n }\n .product-grid.list-view .product-thumb {\n width: 100px; height: 70px; aspect-ratio: auto;\n margin: 0; border-radius: var(--r-sm);\n }\n .product-grid.list-view .product-body {\n padding: 0;\n display: flex; align-items: center; gap: 14px;\n }\n .product-grid.list-view .product-name { font-size: 14px; }\n .product-grid.list-view .product-cat,\n .product-grid.list-view .product-date {\n font-family: var(--font-mono);\n font-size: 11.5px;\n color: var(--black-alpha-48);\n letter-spacing: .02em;\n margin-top: 0;\n }\n .product-grid.list-view .product-footer {\n border-top: 0;\n padding: 0;\n background: transparent;\n gap: 6px;\n }\n\n /* ============================================================\n 批量编辑模式\n ============================================================ */\n .product-card .card-check {\n position: absolute;\n top: 10px; left: 10px;\n width: 22px; height: 22px;\n border-radius: 50%;\n background: var(--surface);\n border: 2px solid var(--black-alpha-32);\n display: none;\n place-items: center;\n color: var(--accent-white);\n z-index: 5;\n pointer-events: none;\n }\n .product-card .card-check svg { width: 11px; height: 11px; opacity: 0; }\n body.edit-mode .product-card { cursor: pointer; }\n body.edit-mode .product-card .card-check { display: grid; }\n body.edit-mode .product-card.selected .card-check {\n background: var(--heat);\n border-color: var(--heat);\n }\n body.edit-mode .product-card.selected .card-check svg { opacity: 1; }\n body.edit-mode .product-card.selected {\n border-color: var(--heat);\n box-shadow: 0 0 0 1px var(--heat) inset;\n }\n /* 编辑模式下,卡片底部 stat 和 more-btn 静默 (不能点击跳转) */\n body.edit-mode .product-footer .stat,\n body.edit-mode .product-card .card-del-btn { opacity: 0 !important; pointer-events: none !important; }\n\n /* 浮动 action bar */\n .bulk-bar {\n position: fixed;\n bottom: 24px;\n left: 50%;\n transform: translateX(-50%);\n background: var(--accent-black);\n color: var(--accent-white);\n border-radius: var(--r-md);\n padding: 10px 14px 10px 18px;\n display: none;\n align-items: center;\n gap: 16px;\n box-shadow: 0 8px 24px rgba(0,0,0,.18);\n z-index: 100;\n font-size: 13px;\n }\n body.edit-mode .bulk-bar { display: inline-flex; }\n .bulk-bar .ct { font-family: var(--font-mono); letter-spacing: .02em; }\n .bulk-bar .ct b { color: var(--heat); font-weight: 700; padding: 0 3px; }\n .bulk-bar .sep { width: 1px; height: 18px; background: rgba(255,255,255,.16); }\n .bulk-bar button {\n height: 30px;\n padding: 0 12px;\n background: transparent;\n border: 1px solid rgba(255,255,255,.24);\n border-radius: var(--r-sm);\n color: var(--accent-white);\n font-size: 12.5px;\n font-family: inherit;\n cursor: pointer;\n display: inline-flex; align-items: center; gap: 5px;\n transition: background var(--t-base), border-color var(--t-base);\n }\n .bulk-bar button:hover { background: rgba(255,255,255,.08); }\n .bulk-bar button.danger {\n background: var(--accent-crimson, #c43d3d);\n border-color: var(--accent-crimson, #c43d3d);\n }\n .bulk-bar button.danger:hover { filter: brightness(1.06); }\n .bulk-bar button svg { width: 12px; height: 12px; }\n .bulk-bar .clear-sel {\n color: rgba(255,255,255,.6);\n font-size: 12px;\n cursor: pointer;\n background: none;\n border: 0;\n padding: 4px 6px;\n }\n .bulk-bar .clear-sel:hover { color: var(--accent-white); }\n\n /* edit-mode 下「编辑商品」按钮变成「完成」 */\n .btn-edit-toggle.active {\n background: var(--accent-black);\n color: var(--accent-white);\n border-color: var(--accent-black);\n }\n</style>\n</head>\n<body>\n<div id=\"page\">\n\n<div class=\"page-head\">\n <div>\n <h1>商品库</h1>\n <div class=\"sub\"><span class=\"mono\">// <span id=\"sku-count\">0</span> SKU</span> · 商品信息会作为脚本和资产生成的素材</div>\n </div>\n <div class=\"actions\">\n <button class=\"btn btn-edit-toggle\" type=\"button\" id=\"edit-toggle-btn\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m3 7 2 2 4-4\"/><path d=\"m3 17 2 2 4-4\"/><path d=\"M13 6h8\"/><path d=\"M13 12h8\"/><path d=\"M13 18h8\"/></svg>\n <span class=\"btn-edit-label\">管理商品</span>\n </button>\n <button class=\"btn btn-primary btn-create\" type=\"button\" id=\"open-new-product\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 22V12\"/><path d=\"M16 17h6\"/><path d=\"M19 14v6\"/><path d=\"M21 10.5V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l1.7-1\"/><path d=\"m3.3 7 8.7 5 8.7-5\"/><path d=\"m7.5 4.3 9 5.1\"/></svg>\n 新建商品\n </button>\n </div>\n</div>\n\n<!-- ===== 主区 (三段式) ===== -->\n<div class=\"products-main\">\n\n <!-- 顶部固定: toolbar + meta -->\n <div class=\"toolbar\">\n <div class=\"search-inline\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/></svg>\n <input class=\"input\" id=\"search-input\" placeholder=\"搜索商品名称、品牌\">\n </div>\n <div class=\"chip-wrap\" data-key=\"cat\">\n <button class=\"chip\" type=\"button\">\n <span class=\"chip-label\">商品分类</span>\n <svg class=\"caret\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg>\n </button>\n <div class=\"chip-menu\"></div>\n </div>\n <div class=\"chip-wrap\" data-key=\"date\">\n <button class=\"chip\" type=\"button\"><span class=\"chip-label\">创建时间</span> <svg class=\"caret\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></button>\n <div class=\"chip-menu\"></div>\n </div>\n <button class=\"clear-filters\" id=\"clear-filters\" type=\"button\" hidden>\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 4l8 8M12 4l-8 8\"/></svg>\n 清空筛选\n </button>\n </div>\n\n <div class=\"result-meta\" id=\"result-meta\">\n <span>// 显示 <span class=\"count\">7</span> / 7 个商品</span>\n <div class=\"view-tog\" style=\"margin-left:auto\" id=\"view-tog\">\n <button type=\"button\" class=\"active\" data-view=\"grid\" title=\"网格视图\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><rect x=\"3\" y=\"3\" width=\"7\" height=\"7\"/><rect x=\"14\" y=\"3\" width=\"7\" height=\"7\"/><rect x=\"3\" y=\"14\" width=\"7\" height=\"7\"/><rect x=\"14\" y=\"14\" width=\"7\" height=\"7\"/></svg>\n </button>\n <button type=\"button\" data-view=\"list\" title=\"列表视图\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M3 6h18M3 12h18M3 18h18\"/></svg>\n </button>\n </div>\n </div>\n\n <!-- 中间滚动: 商品网格 -->\n <div class=\"product-grid-wrap\">\n <div class=\"product-grid\" id=\"product-grid\">\n <div class=\"product-card\" data-cat=\"美妆个护\" data-name=\"透真玻尿酸补水面膜\" data-tags=\"熬夜党 敏感肌\" data-added=\"1\" data-assets=\"124\" data-videos=\"36\" data-date=\"2026-05-15\" onclick=\"location.href='product-detail.html?t='+Date.now()\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除商品\" onclick=\"event.stopPropagation();\" data-action=\"delete-product\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder product-thumb\"><span class=\"ph-frame\">补水面膜 · 1200×800</span></div>\n <div class=\"product-body\">\n <div class=\"product-name\">透真玻尿酸补水面膜</div>\n <div class=\"product-cat\">美妆个护</div>\n <div class=\"product-date\">2026-05-15 创建</div>\n </div>\n <div class=\"product-footer\">\n <span class=\"stat\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/><circle cx=\"9\" cy=\"10\" r=\"2\"/><path d=\"M21 17l-5-5-9 9\"/></svg>\n 素材 <b>124</b>\n </span>\n <span class=\"sep\">·</span>\n <span class=\"stat\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2\" y=\"6\" width=\"14\" height=\"12\" rx=\"2\"/><path d=\"M16 10l6-3v10l-6-3z\"/></svg>\n 视频 <b>36</b>\n </span>\n \n </div>\n </div>\n\n <div class=\"product-card\" data-cat=\"数码 3C\" data-name=\"南卡 Lite Pro 蓝牙耳机\" data-tags=\"通勤 运动\" data-added=\"2\" data-assets=\"96\" data-videos=\"28\" data-date=\"2026-05-12\" onclick=\"location.href='product-detail.html?t='+Date.now()\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除商品\" onclick=\"event.stopPropagation();\" data-action=\"delete-product\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder product-thumb\"><span class=\"ph-frame\">蓝牙耳机 · 1200×800</span></div>\n <div class=\"product-body\">\n <div class=\"product-name\">南卡 Lite Pro 蓝牙耳机</div>\n <div class=\"product-cat\">数码 3C</div>\n <div class=\"product-date\">2026-05-12 创建</div>\n </div>\n <div class=\"product-footer\">\n <span class=\"stat\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/><circle cx=\"9\" cy=\"10\" r=\"2\"/><path d=\"M21 17l-5-5-9 9\"/></svg>\n 素材 <b>96</b>\n </span>\n <span class=\"sep\">·</span>\n <span class=\"stat\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2\" y=\"6\" width=\"14\" height=\"12\" rx=\"2\"/><path d=\"M16 10l6-3v10l-6-3z\"/></svg>\n 视频 <b>28</b>\n </span>\n \n </div>\n </div>\n\n <div class=\"product-card\" data-cat=\"食品饮料\" data-name=\"滋啦速食牛肉面 6 桶装\" data-tags=\"加班 独居\" data-added=\"3\" data-assets=\"96\" data-videos=\"24\" data-date=\"2026-05-10\" onclick=\"location.href='product-detail.html?t='+Date.now()\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除商品\" onclick=\"event.stopPropagation();\" data-action=\"delete-product\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder product-thumb\"><span class=\"ph-frame\">速食牛肉面 · 1200×800</span></div>\n <div class=\"product-body\">\n <div class=\"product-name\">滋啦速食牛肉面 · 6 桶装</div>\n <div class=\"product-cat\">食品饮料</div>\n <div class=\"product-date\">2026-05-10 创建</div>\n </div>\n <div class=\"product-footer\">\n <span class=\"stat\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/><circle cx=\"9\" cy=\"10\" r=\"2\"/><path d=\"M21 17l-5-5-9 9\"/></svg>\n 素材 <b>96</b>\n </span>\n <span class=\"sep\">·</span>\n <span class=\"stat\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2\" y=\"6\" width=\"14\" height=\"12\" rx=\"2\"/><path d=\"M16 10l6-3v10l-6-3z\"/></svg>\n 视频 <b>24</b>\n </span>\n \n </div>\n </div>\n\n <div class=\"product-card\" data-cat=\"美妆个护\" data-name=\"透真清透物理防晒霜\" data-tags=\"SPF50 通勤\" data-added=\"4\" data-assets=\"76\" data-videos=\"18\" data-date=\"2026-05-08\" onclick=\"location.href='product-detail.html?t='+Date.now()\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除商品\" onclick=\"event.stopPropagation();\" data-action=\"delete-product\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder product-thumb\"><span class=\"ph-frame\">防晒霜 · 1200×800</span></div>\n <div class=\"product-body\">\n <div class=\"product-name\">透真清透物理防晒霜</div>\n <div class=\"product-cat\">美妆个护</div>\n <div class=\"product-date\">2026-05-08 创建</div>\n </div>\n <div class=\"product-footer\">\n <span class=\"stat\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/><circle cx=\"9\" cy=\"10\" r=\"2\"/><path d=\"M21 17l-5-5-9 9\"/></svg>\n 素材 <b>76</b>\n </span>\n <span class=\"sep\">·</span>\n <span class=\"stat\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2\" y=\"6\" width=\"14\" height=\"12\" rx=\"2\"/><path d=\"M16 10l6-3v10l-6-3z\"/></svg>\n 视频 <b>18</b>\n </span>\n \n </div>\n </div>\n\n <div class=\"product-card\" data-cat=\"食品饮料\" data-name=\"三顿半同款冻干咖啡粉\" data-tags=\"提神 早八\" data-added=\"5\" data-assets=\"68\" data-videos=\"21\" data-date=\"2026-05-05\" onclick=\"location.href='product-detail.html?t='+Date.now()\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除商品\" onclick=\"event.stopPropagation();\" data-action=\"delete-product\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder product-thumb\"><span class=\"ph-frame\">咖啡冻干粉 · 1200×800</span></div>\n <div class=\"product-body\">\n <div class=\"product-name\">三顿半同款冻干咖啡粉</div>\n <div class=\"product-cat\">食品饮料</div>\n <div class=\"product-date\">2026-05-05 创建</div>\n </div>\n <div class=\"product-footer\">\n <span class=\"stat\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/><circle cx=\"9\" cy=\"10\" r=\"2\"/><path d=\"M21 17l-5-5-9 9\"/></svg>\n 素材 <b>68</b>\n </span>\n <span class=\"sep\">·</span>\n <span class=\"stat\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2\" y=\"6\" width=\"14\" height=\"12\" rx=\"2\"/><path d=\"M16 10l6-3v10l-6-3z\"/></svg>\n 视频 <b>21</b>\n </span>\n \n </div>\n </div>\n\n <div class=\"product-card\" data-cat=\"家居家电\" data-name=\"小熊 4L 可视空气炸锅\" data-tags=\"小户型 健康\" data-added=\"6\" data-assets=\"54\" data-videos=\"16\" data-date=\"2026-05-03\" onclick=\"location.href='product-detail.html?t='+Date.now()\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除商品\" onclick=\"event.stopPropagation();\" data-action=\"delete-product\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder product-thumb\"><span class=\"ph-frame\">空气炸锅 · 1200×800</span></div>\n <div class=\"product-body\">\n <div class=\"product-name\">小熊 4L 可视空气炸锅</div>\n <div class=\"product-cat\">家居家电</div>\n <div class=\"product-date\">2026-05-03 创建</div>\n </div>\n <div class=\"product-footer\">\n <span class=\"stat\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/><circle cx=\"9\" cy=\"10\" r=\"2\"/><path d=\"M21 17l-5-5-9 9\"/></svg>\n 素材 <b>54</b>\n </span>\n <span class=\"sep\">·</span>\n <span class=\"stat\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2\" y=\"6\" width=\"14\" height=\"12\" rx=\"2\"/><path d=\"M16 10l6-3v10l-6-3z\"/></svg>\n 视频 <b>16</b>\n </span>\n \n </div>\n </div>\n\n <div class=\"product-card\" data-cat=\"运动户外\" data-name=\"露露同款裸感瑜伽裤\" data-tags=\"健身房 通勤\" data-added=\"7\" data-assets=\"42\" data-videos=\"12\" data-date=\"2026-04-30\" onclick=\"location.href='product-detail.html?t='+Date.now()\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除商品\" onclick=\"event.stopPropagation();\" data-action=\"delete-product\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder product-thumb\"><span class=\"ph-frame\">瑜伽裤 · 1200×800</span></div>\n <div class=\"product-body\">\n <div class=\"product-name\">露露同款裸感瑜伽裤</div>\n <div class=\"product-cat\">运动户外</div>\n <div class=\"product-date\">2026-04-30 创建</div>\n </div>\n <div class=\"product-footer\">\n <span class=\"stat\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/><circle cx=\"9\" cy=\"10\" r=\"2\"/><path d=\"M21 17l-5-5-9 9\"/></svg>\n 素材 <b>42</b>\n </span>\n <span class=\"sep\">·</span>\n <span class=\"stat\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2\" y=\"6\" width=\"14\" height=\"12\" rx=\"2\"/><path d=\"M16 10l6-3v10l-6-3z\"/></svg>\n 视频 <b>12</b>\n </span>\n \n </div>\n </div>\n </div>\n\n <div class=\"empty-state\" id=\"empty\">\n <div class=\"ic-empty\">\n <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/></svg>\n </div>\n <h3>没有匹配的商品</h3>\n <p>// 试试切换分类或修改搜索词</p>\n </div>\n </div>\n\n <!-- 底部固定: 分页 -->\n <div class=\"pagination\" id=\"pagination\" hidden>\n <span class=\"total\">共 <b id=\"page-total\">0</b> 条</span>\n <button class=\"page-size\" type=\"button\" id=\"page-size-btn\" title=\"切换每页条数\">\n <span id=\"page-size-label\">12 条/页</span>\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg>\n </button>\n <span class=\"pages\" id=\"page-list\"></span>\n <span class=\"jump\">跳至 <input type=\"number\" min=\"1\" value=\"1\" id=\"page-jump\"> 页</span>\n </div>\n\n</div><!-- /.products-main -->\n\n</div><!-- /#page -->\n\n<!-- ============================================================\n 新建商品 · 右侧 Drawer · 在商品库页面原地打开\n ============================================================ -->\n<div class=\"drawer-bg\" id=\"pc-drawer-bg\"></div>\n<aside class=\"drawer pc-drawer\" id=\"pc-drawer\" role=\"dialog\" aria-label=\"新建商品\" aria-hidden=\"true\">\n <div class=\"drawer-h\">\n <h3>新建商品</h3>\n <button class=\"x\" type=\"button\" id=\"pc-drawer-close\" aria-label=\"关闭\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6 6l12 12M6 18L18 6\"/></svg>\n </button>\n </div>\n\n <div class=\"drawer-b\">\n <div class=\"form-card\">\n <div class=\"field\">\n <label class=\"field-label\">商品名称<span class=\"req\">*</span></label>\n <input class=\"input\" id=\"pf-name\" placeholder=\"请输入商品名称(必填)\" maxlength=\"100\">\n </div>\n\n <div class=\"field-row\">\n <div>\n <label class=\"field-label\">品类<span class=\"req\">*</span></label>\n <select class=\"select\" id=\"pf-cat\">\n <option>美妆个护</option>\n <option>服饰内衣</option>\n <option>食品饮料</option>\n <option>家居家电</option>\n <option>数码 3C</option>\n <option>个护清洁</option>\n <option>运动户外</option>\n <option>母婴亲子</option>\n </select>\n </div>\n <div>\n <label class=\"field-label\">目标人群<span class=\"opt\">(选填)</span></label>\n <input class=\"input\" id=\"pf-target\" placeholder=\"例: 22-32 岁女性、敏感肌、办公室通勤\">\n </div>\n </div>\n\n <div class=\"field\">\n <label class=\"field-label\">商品主图<span class=\"req\">*</span></label>\n <input type=\"file\" id=\"pf-file\" accept=\"image/*\" multiple hidden>\n <div class=\"pf-upload-row\">\n <div class=\"pf-upload-zone\" id=\"pf-zone\">\n <div class=\"uz-ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12\"/></svg>\n </div>\n <div class=\"uz-t\">点击上传或<strong>拖拽图片</strong>到此处</div>\n <div class=\"uz-d\">// 支持 JPG、PNG 格式,建议尺寸 800×800 以上,大小不超过 10MB</div>\n </div>\n <div class=\"pf-example\">\n <div class=\"ex-h\">示例图</div>\n <div class=\"ex-grid\">\n <div class=\"ex-thumb\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M7 4h10l1 4v12H6V8l1-4z\"/><path d=\"M9 4v3M15 4v3M9 11h6M9 14h6\"/></svg></div>\n <div class=\"ex-thumb\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"6\" y=\"5\" width=\"12\" height=\"15\" rx=\"2\"/><path d=\"M9 9h6M9 12h6M9 15h4\"/></svg></div>\n <div class=\"ex-thumb\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M8 3h8l1 5v12H7V8l1-5z\"/><circle cx=\"12\" cy=\"13\" r=\"2.5\"/></svg></div>\n </div>\n <div class=\"ex-d\">优质的商品图有助于生成更好的素材效果</div>\n </div>\n </div>\n <div class=\"pf-grid\" id=\"pf-grid\"></div>\n </div>\n\n <div class=\"field\" style=\"margin-bottom: 0;\">\n <label class=\"field-label\">核心卖点<span class=\"req\">*</span></label>\n <ul class=\"bullet-list\" id=\"pf-bullets\" data-bl>\n <li class=\"bl-add\"><span class=\"num\">+</span><input class=\"bl-input\" placeholder=\"添加新卖点 · 回车确认\"></li>\n </ul>\n </div>\n </div>\n </div>\n\n <div class=\"drawer-f\">\n <button class=\"btn-guide\" type=\"button\" onclick=\"Shell.toast('使用指南', '点击查看完整填写指南')\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M9.5 9a2.5 2.5 0 015 0c0 1.5-2.5 2-2.5 4M12 17h.01\"/></svg>\n 使用指南\n </button>\n <button class=\"btn\" type=\"button\" id=\"pc-cancel-btn\">取消</button>\n <button class=\"btn btn-primary\" type=\"button\" id=\"pc-save-btn\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 12l5 5L20 6\"/></svg>\n 创建商品\n </button>\n </div>\n</aside>\n\n<!-- ===== 生成类型选择 modal ===== -->\n<div class=\"modal-bg\" id=\"gen-choice-bg\">\n <div class=\"modal\" role=\"dialog\" aria-labelledby=\"gen-choice-ti\" style=\"max-width:560px\">\n <span class=\"corner-tr\" aria-hidden></span>\n <span class=\"corner-bl\" aria-hidden></span>\n <div class=\"modal-h\">\n <div class=\"ic-m\" style=\"background:var(--heat-12);color:var(--heat)\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83\"/></svg>\n </div>\n <div class=\"ti\" id=\"gen-choice-ti\"><span id=\"gen-choice-target\">—</span><span>// PICK ONE</span></div>\n </div>\n <div class=\"modal-b\" style=\"padding-top:12px\">\n <div class=\"gen-choice-grid\">\n <button type=\"button\" class=\"gen-choice-card\" data-go=\"model-photo\">\n <div class=\"gc-ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"8\" r=\"4\"/><path d=\"M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8\"/></svg>\n </div>\n <div class=\"gc-t\">模特上身图</div>\n <div class=\"gc-d\">// 选模特 + 商品 → AI 生成穿搭/试用图</div>\n </button>\n <button type=\"button\" class=\"gen-choice-card\" data-go=\"platform-cover\">\n <div class=\"gc-ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"5\" width=\"18\" height=\"14\" rx=\"2\"/><path d=\"M3 9h18M9 5v14\"/></svg>\n </div>\n <div class=\"gc-t\">平台套图</div>\n <div class=\"gc-d\">// 多平台尺寸自动生成 (抖音/淘宝/小红书等)</div>\n </button>\n </div>\n </div>\n <div class=\"modal-f\">\n <button class=\"btn\" type=\"button\" id=\"gen-choice-cancel\">取消</button>\n </div>\n </div>\n</div>\n\n<!-- ===== 删除确认 modal ===== -->\n<div class=\"modal-bg\" id=\"del-confirm-bg\">\n <div class=\"modal\" role=\"dialog\" aria-labelledby=\"del-confirm-ti\">\n <span class=\"corner-tr\" aria-hidden></span>\n <span class=\"corner-bl\" aria-hidden></span>\n <div class=\"modal-h\">\n <div class=\"ic-m\" style=\"background:var(--crimson-bg,#fdebea);color:var(--accent-crimson,#c43d3d)\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg>\n </div>\n <div class=\"ti\" id=\"del-confirm-ti\">确认删除商品<span>// CONFIRM DELETE</span></div>\n </div>\n <div class=\"modal-b\" id=\"del-confirm-body\">即将删除 <span class=\"mono-acc\" id=\"del-confirm-target\">—</span>,此操作无法撤销,商品下生成的素材记录也将一并清理。</div>\n <div class=\"modal-f\" id=\"del-confirm-foot\">\n <button class=\"btn\" type=\"button\" id=\"del-confirm-cancel\">取消</button>\n <button class=\"btn\" type=\"button\" id=\"del-confirm-ok\" style=\"background:var(--accent-crimson,#c43d3d);color:var(--accent-white);border-color:var(--accent-crimson,#c43d3d)\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/></svg>\n 确认删除\n </button>\n </div>\n </div>\n</div>\n\n<!-- ===== 批量编辑 浮动 action bar ===== -->\n<div class=\"bulk-bar\" id=\"bulk-bar\">\n <span class=\"ct\">已选 <b id=\"bulk-count\">0</b> 项</span>\n <button class=\"clear-sel\" type=\"button\" id=\"bulk-clear\">清空</button>\n <span class=\"sep\"></span>\n <button class=\"danger\" type=\"button\" id=\"bulk-del\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg>\n 删除所选\n </button>\n <button type=\"button\" id=\"bulk-exit\">完成</button>\n</div>\n\n<script src=\"/exact/assets/icons.js?v=2026052608\"></script>\n<script src=\"/exact/assets/shell.js?v=2026060101\"></script>\n<script>\nShell.render({ active: 'products', crumbs: [{ label: '工作台', href: 'index.html' }, { label: '商品库' }] });\n\n// ============== 注入用户新建的商品(来自工作台 / 商品库 drawer,写入 localStorage)==============\n// 必须在 const cards / TOTAL / CAT_COUNT 之前执行,让后续逻辑把它们当普通商品处理\n(function injectExtraProducts() {\n // 一次性清掉历史遗留的 localStorage 旧数据(用户上次会话误持久化的占位商品)\n try { localStorage.removeItem('fs-extra-products'); } catch (e) {}\n let pending;\n try {\n pending = JSON.parse(sessionStorage.getItem('fs-extra-products') || '[]');\n } catch (e) { return; }\n if (!Array.isArray(pending) || !pending.length) return;\n const grid = document.getElementById('product-grid');\n if (!grid) return;\n const esc = s => String(s == null ? '' : s).replace(/[&<>\"']/g, c => ({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));\n\n // 按 createdAt 升序排,然后逐个 insertBefore firstChild → 最新的排最上\n pending.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));\n pending.forEach(p => {\n const card = document.createElement('div');\n card.className = 'product-card';\n card.dataset.cat = p.cat || '美妆个护';\n card.dataset.name = p.name || '';\n card.dataset.tags = p.tags || '';\n card.dataset.added = '0';\n card.dataset.assets = String(p.assets || 0);\n card.dataset.videos = String(p.videos || 0);\n card.dataset.date = p.date || new Date(p.createdAt || Date.now()).toISOString().slice(0, 10);\n card.setAttribute('onclick', `location.href='product-detail.html?t='+Date.now()+'&product=${encodeURIComponent(p.name || '')}'`);\n card.dataset.triview = '0';\n card.innerHTML = `\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除商品\" onclick=\"event.stopPropagation();\" data-action=\"delete-product\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder product-thumb\">\n <span class=\"tri-missing-badge\" tabindex=\"0\" role=\"button\" aria-label=\"缺三视图,查看说明\">\n <span class=\"ico\" aria-hidden=\"true\"></span>\n <span class=\"lbl-mono\">缺三视图</span>\n <span class=\"tri-missing-pop\" role=\"tooltip\">\n <span class=\"pop-h\">\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 9v4M12 17h.01\"/><path d=\"M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z\"/></svg>\n MISSING TRI-VIEW\n </span>\n <span class=\"pop-body\">该商品还未生成 <b>正 / 侧 / 背</b> 三视图。直接生成图片或视频,模型缺少多角度参考,角色一致性、姿态稳定性可能下降。</span>\n <span class=\"pop-tip\">建议:进入 <b>商品详情</b> 先补齐三视图,再发起后续生成。</span>\n </span>\n </span>\n <span class=\"ph-frame\">${esc(p.name)} · 新建</span>\n </div>\n <div class=\"product-body\">\n <div class=\"product-name\">${esc(p.name)}</div>\n <div class=\"product-cat\">${esc(p.cat || '美妆个护')}</div>\n <div class=\"product-date\">${esc(p.date || '')} 创建</div>\n </div>\n <div class=\"product-footer\">\n <span class=\"stat\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/><circle cx=\"9\" cy=\"10\" r=\"2\"/><path d=\"M21 17l-5-5-9 9\"/></svg>\n 素材 <b>${p.assets || 0}</b>\n </span>\n <span class=\"sep\">·</span>\n <span class=\"stat\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2\" y=\"6\" width=\"14\" height=\"12\" rx=\"2\"/><path d=\"M16 10l6-3v10l-6-3z\"/></svg>\n 视频 <b>${p.videos || 0}</b>\n </span>\n </div>\n `;\n grid.insertBefore(card, grid.firstChild);\n });\n})();\n\n// ============== Products: 商品分类多选 + 搜索 + 创建时间筛选 ==============\n// state.cats: Set<string>,空集 = 全部\nconst PAGE_SIZES = [12, 24, 48, 96];\nconst state = { cats: new Set(), search: '', date: 'all', page: 1, pageSize: 12 };\n\n// 抖音爆款品类 · TOP 8 (全部显示,即使没商品)\nconst CATEGORIES = ['美妆个护', '服饰内衣', '食品饮料', '家居家电', '数码 3C', '个护清洁', '运动户外', '母婴亲子'];\n\nconst DATE_LABEL = {\n 'all': '全部时间',\n '7d': '最近 7 天',\n '30d': '最近 30 天',\n '90d': '最近 90 天',\n};\n\nconst grid = document.getElementById('product-grid');\nconst cards = [...grid.querySelectorAll('.product-card')];\nconst TOTAL = cards.length;\nconst CAT_COUNT = Object.fromEntries(CATEGORIES.map(c =>\n [c, cards.filter(card => card.dataset.cat === c).length]\n));\n\n// 同步计数: 大标题 SKU + 侧栏徽章\ndocument.getElementById('sku-count').textContent = TOTAL;\nconst sidebarBadge = document.querySelector('aside.sidebar a[href=\"products.html\"] .pill-mini');\nif (sidebarBadge) sidebarBadge.textContent = TOTAL;\n\n// ─── 卡片底部 stat 增强 · hover 切换文案 + 直跳生成入口 ───\ncards.forEach(card => {\n card.querySelectorAll('.product-footer .stat').forEach(stat => {\n const text = stat.textContent.trim();\n const m = text.match(/^(素材|视频)\\s*(\\d+)/);\n if (!m) return;\n const label = m[1];\n const num = m[2];\n const isAsset = label === '素材';\n const cta = isAsset ? '去生成素材' : '去生成视频';\n const svg = stat.querySelector('svg');\n const icon = window.IconKit\n ? window.IconKit.svg(isAsset ? 'images' : 'video', {\n size: 14,\n strokeWidth: 1.25,\n className: 'product-stat-icon'\n })\n : (svg ? svg.outerHTML : '');\n // 重组结构: <svg> <span.stat-default>素材 <b>N</b></span> <span.stat-hover>去生成素材</span>\n stat.innerHTML = '';\n if (icon) stat.insertAdjacentHTML('beforeend', icon);\n const dft = document.createElement('span');\n dft.className = 'stat-default';\n dft.innerHTML = `${label} <b>${num}</b>`;\n const hv = document.createElement('span');\n hv.className = 'stat-hover';\n hv.textContent = cta.replace(/^\\s*\\+\\s*/, '');\n stat.appendChild(dft);\n stat.appendChild(hv);\n stat.dataset.type = isAsset ? 'asset' : 'video';\n stat.title = isAsset ? '去生成 AI 素材' : '去生成视频项目';\n stat.addEventListener('click', e => {\n e.stopPropagation();\n const name = card.dataset.name || '';\n if (isAsset) {\n // 弹出选择: 模特上身图 / 平台套图\n openGenAssetChoice(name);\n } else {\n location.href = 'projects-new.html?t=' + Date.now() + '&product=' + encodeURIComponent(name);\n }\n });\n });\n});\n\n// ─── 生成类型选择 modal (素材生成 → 选 模特上身图 / 平台套图) ───\nconst genChoiceBg = document.getElementById('gen-choice-bg');\nconst genChoiceTarget = document.getElementById('gen-choice-target');\nconst genChoiceCancel = document.getElementById('gen-choice-cancel');\nlet _genChoiceProduct = '';\nfunction openGenAssetChoice(productName) {\n _genChoiceProduct = productName || '';\n genChoiceTarget.textContent = _genChoiceProduct || '该商品';\n genChoiceBg.classList.add('show');\n document.body.style.overflow = 'hidden';\n}\nfunction closeGenAssetChoice() {\n genChoiceBg.classList.remove('show');\n document.body.style.overflow = '';\n}\ngenChoiceCancel.addEventListener('click', closeGenAssetChoice);\ngenChoiceBg.addEventListener('click', e => {\n if (e.target === genChoiceBg) closeGenAssetChoice();\n});\ngenChoiceBg.querySelectorAll('.gen-choice-card').forEach(btn => {\n btn.addEventListener('click', () => {\n const go = btn.dataset.go;\n const target = (go === 'model-photo') ? 'model-photo.html' : 'platform-cover.html';\n const url = target + '?t=' + Date.now() + '&product=' + encodeURIComponent(_genChoiceProduct);\n closeGenAssetChoice();\n location.href = url;\n });\n});\n\n// ─── 删除确认 modal (PRD §6.3: 已被项目引用时禁止强删) ───\n// 模拟: 某些商品被项目引用 (data-refs=\"项目A,项目B\")\n// 真实环境从后台查 product → projects 映射\nconst PRODUCT_REFS = {\n '透真玻尿酸补水面膜': ['夏日水嫩计划', '七夕主题推广'],\n '南卡 Lite Pro 蓝牙耳机': ['通勤好物']\n};\nfunction getRefs(card) {\n const name = card.dataset.name || '';\n return PRODUCT_REFS[name] || [];\n}\n\nconst delBg = document.getElementById('del-confirm-bg');\nconst delBody = document.getElementById('del-confirm-body');\nconst delFoot = document.getElementById('del-confirm-foot');\nconst delCancel = document.getElementById('del-confirm-cancel');\nconst delOk = document.getElementById('del-confirm-ok');\nlet _delQueue = [];\n\nfunction setFootDeletable() {\n delFoot.innerHTML = '';\n delFoot.appendChild(delCancel);\n delFoot.appendChild(delOk);\n}\nfunction setFootBlocked() {\n delFoot.innerHTML = '';\n const okBtn = document.createElement('button');\n okBtn.className = 'btn btn-primary';\n okBtn.type = 'button';\n okBtn.textContent = '我知道了';\n okBtn.addEventListener('click', closeDelConfirm);\n delFoot.appendChild(okBtn);\n}\n\nfunction openDelConfirm(targets) {\n // 分组: 可删除 / 被引用 (PRD §6.3 软删除规则)\n const blocked = targets.filter(c => getRefs(c).length > 0);\n const deletable = targets.filter(c => getRefs(c).length === 0);\n\n // 全部被引用 → 阻断式提示\n if (deletable.length === 0 && blocked.length > 0) {\n const c = blocked[0];\n const refs = getRefs(c);\n if (blocked.length === 1) {\n delBody.innerHTML = `<span class=\"mono-acc\">${c.dataset.name}</span> 当前被 <b>${refs.length}</b> 个项目使用,无法直接删除。请先在以下项目中解除引用:<br><br>` +\n refs.map(r => `<span class=\"mono-acc\" style=\"margin-right:6px\">${r}</span>`).join('');\n } else {\n delBody.innerHTML = `所选 <b>${blocked.length}</b> 个商品均被项目引用,无法直接删除。请先解除引用后重试。`;\n }\n setFootBlocked();\n delBg.classList.add('show');\n _delQueue = [];\n return;\n }\n\n _delQueue = deletable;\n if (deletable.length === 1 && blocked.length === 0) {\n delBody.innerHTML = '即将删除 <span class=\"mono-acc\">' + deletable[0].dataset.name + '</span>,此操作无法撤销,商品下生成的素材记录也将一并清理。';\n } else if (blocked.length > 0) {\n delBody.innerHTML = '即将删除 <span class=\"mono-acc\">' + deletable.length + ' 个商品</span>,其中 <b>' + blocked.length + '</b> 个被项目引用已跳过。可删除的将一并清理素材记录。';\n } else {\n delBody.innerHTML = '即将删除 <span class=\"mono-acc\">' + deletable.length + ' 个商品</span>,此操作无法撤销,这些商品下生成的素材记录也将一并清理。';\n }\n setFootDeletable();\n delBg.classList.add('show');\n}\nfunction closeDelConfirm() { delBg.classList.remove('show'); _delQueue = []; }\ndelCancel.addEventListener('click', closeDelConfirm);\ndelBg.addEventListener('click', e => { if (e.target === delBg) closeDelConfirm(); });\ndelOk.addEventListener('click', () => {\n const n = _delQueue.length;\n _delQueue.forEach(card => card.remove());\n closeDelConfirm();\n const remaining = document.querySelectorAll('.product-card').length;\n document.getElementById('sku-count').textContent = remaining;\n const meta = document.querySelector('#result-meta .count');\n if (meta) meta.textContent = remaining;\n const sidebar = document.querySelector('aside.sidebar a[href=\"products.html\"] .pill-mini');\n if (sidebar) sidebar.textContent = remaining;\n Shell.toast('已删除', n === 1 ? '商品已移除' : '已删除 ' + n + ' 个商品');\n updateBulkBar();\n});\n\n// ─── 单卡片删除 (新统一 card-del-btn) ───\ndocument.querySelectorAll('.product-card .card-del-btn').forEach(btn => {\n btn.addEventListener('click', e => {\n e.stopPropagation();\n const card = btn.closest('.product-card');\n if (!card) return;\n openDelConfirm([card]);\n });\n});\n\n// ─── 视图切换 网格 / 列表 ───\nconst productGrid = document.getElementById('product-grid');\ndocument.querySelectorAll('#view-tog button').forEach(b => {\n b.addEventListener('click', () => {\n document.querySelectorAll('#view-tog button').forEach(x => x.classList.remove('active'));\n b.classList.add('active');\n if (b.dataset.view === 'list') productGrid.classList.add('list-view');\n else productGrid.classList.remove('list-view');\n });\n});\n\n// ─── 批量编辑模式 ───\nconst editToggleBtn = document.getElementById('edit-toggle-btn');\nconst bulkBar = document.getElementById('bulk-bar');\nconst bulkCount = document.getElementById('bulk-count');\nconst bulkClear = document.getElementById('bulk-clear');\nconst bulkDel = document.getElementById('bulk-del');\nconst bulkExit = document.getElementById('bulk-exit');\nconst editLabel = editToggleBtn.querySelector('.btn-edit-label');\n\nfunction getSelectedCards() {\n return [...document.querySelectorAll('.product-card.selected')];\n}\nfunction updateBulkBar() {\n const sel = getSelectedCards();\n bulkCount.textContent = sel.length;\n bulkDel.disabled = sel.length === 0;\n bulkDel.style.opacity = sel.length === 0 ? '.4' : '1';\n bulkDel.style.cursor = sel.length === 0 ? 'not-allowed' : 'pointer';\n}\nfunction enterEditMode() {\n document.body.classList.add('edit-mode');\n editToggleBtn.classList.add('active');\n editLabel.textContent = '完成';\n updateBulkBar();\n}\nfunction exitEditMode() {\n document.body.classList.remove('edit-mode');\n editToggleBtn.classList.remove('active');\n editLabel.textContent = '管理商品';\n document.querySelectorAll('.product-card.selected').forEach(c => c.classList.remove('selected'));\n}\neditToggleBtn.addEventListener('click', () => {\n if (document.body.classList.contains('edit-mode')) exitEditMode();\n else enterEditMode();\n});\nbulkExit.addEventListener('click', exitEditMode);\nbulkClear.addEventListener('click', () => {\n document.querySelectorAll('.product-card.selected').forEach(c => c.classList.remove('selected'));\n updateBulkBar();\n});\nbulkDel.addEventListener('click', () => {\n const sel = getSelectedCards();\n if (!sel.length) return;\n openDelConfirm(sel);\n});\n\n// 编辑模式下,卡片点击切换 selected (不再跳详情)\ndocument.querySelectorAll('.product-card').forEach(card => {\n card.addEventListener('click', e => {\n if (!document.body.classList.contains('edit-mode')) return; // 非编辑模式走原 onclick (跳详情)\n // capture 阶段必须用 stopImmediatePropagation 才能阻止 element.onclick (inline onclick=\"\")\n e.stopImmediatePropagation();\n e.preventDefault();\n card.classList.toggle('selected');\n updateBulkBar();\n }, true); // capture: 早于 inline onclick\n});\n\nconst checkSvg = '<svg class=\"mi-check\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg>';\n\n// ====== 商品分类 chip · 多选 ======\nconst catWrap = document.querySelector('.chip-wrap[data-key=\"cat\"]');\nconst catMenu = catWrap.querySelector('.chip-menu');\n\nfunction renderCatMenu() {\n const allSelected = state.cats.size === 0;\n const allRow = `<div class=\"mi mi-all${allSelected ? ' selected' : ''}\" data-value=\"__all__\">${checkSvg}<span>全部商品</span><span class=\"cat-count\">${TOTAL}</span></div>`;\n const items = CATEGORIES\n .filter(c => CAT_COUNT[c] > 0)\n .map(c => {\n const sel = state.cats.has(c);\n const n = CAT_COUNT[c];\n return `<div class=\"mi${sel ? ' selected' : ''}\" data-value=\"${c}\">${checkSvg}<span>${c}</span><span class=\"cat-count\">${n}</span></div>`;\n }).join('');\n catMenu.innerHTML = allRow + items;\n\n catMenu.querySelectorAll('.mi').forEach(mi => {\n mi.addEventListener('click', e => {\n e.stopPropagation();\n const v = mi.dataset.value;\n if (v === '__all__') {\n state.cats.clear();\n } else {\n if (state.cats.has(v)) state.cats.delete(v);\n else state.cats.add(v);\n }\n renderCatMenu(); // 不关菜单,只刷新 selected 态\n syncCatChip();\n state.page = 1;\n applyFilter();\n });\n });\n}\n\nfunction syncCatChip() {\n const label = catWrap.querySelector('.chip-label');\n const chip = catWrap.querySelector('.chip');\n // 先清掉旧的 chip-count\n chip.querySelectorAll('.chip-count').forEach(n => n.remove());\n if (state.cats.size === 0) {\n label.textContent = '商品分类';\n chip.classList.remove('active');\n } else if (state.cats.size === 1) {\n label.textContent = [...state.cats][0];\n chip.classList.add('active');\n } else {\n label.textContent = '商品分类';\n const count = document.createElement('span');\n count.className = 'chip-count';\n count.textContent = state.cats.size;\n // 把计数徽标插在 caret 之前\n chip.insertBefore(count, chip.querySelector('.caret'));\n chip.classList.add('active');\n }\n}\n\ncatWrap.querySelector('.chip').addEventListener('click', e => {\n e.stopPropagation();\n const isOpen = catWrap.classList.contains('open');\n document.querySelectorAll('.chip-wrap.open').forEach(w => w.classList.remove('open'));\n if (!isOpen) catWrap.classList.add('open');\n});\n// menu 内点击不冒泡到 document(防止误关)\ncatMenu.addEventListener('click', e => e.stopPropagation());\n\nrenderCatMenu();\n\n// ====== 创建时间 chip ======\nconst dateWrap = document.querySelector('.chip-wrap[data-key=\"date\"]');\ndateWrap.querySelector('.chip-menu').innerHTML = Object.entries(DATE_LABEL).map(([v, l]) =>\n `<div class=\"mi${v === 'all' ? ' selected' : ''}\" data-value=\"${v}\">${checkSvg}<span>${l}</span></div>`\n).join('');\n\nfunction syncDateChip() {\n const label = dateWrap.querySelector('.chip-label');\n const chip = dateWrap.querySelector('.chip');\n if (state.date === 'all') {\n label.textContent = '创建时间';\n chip.classList.remove('active');\n } else {\n label.textContent = DATE_LABEL[state.date];\n chip.classList.add('active');\n }\n dateWrap.querySelectorAll('.mi').forEach(mi => mi.classList.toggle('selected', mi.dataset.value === state.date));\n}\n\ndateWrap.querySelector('.chip').addEventListener('click', e => {\n e.stopPropagation();\n const isOpen = dateWrap.classList.contains('open');\n document.querySelectorAll('.chip-wrap.open').forEach(w => w.classList.remove('open'));\n if (!isOpen) dateWrap.classList.add('open');\n});\ndateWrap.querySelectorAll('.mi').forEach(mi => {\n mi.addEventListener('click', e => {\n e.stopPropagation();\n state.date = mi.dataset.value;\n syncDateChip();\n dateWrap.classList.remove('open');\n state.page = 1;\n applyFilter();\n });\n});\ndocument.addEventListener('click', () => {\n document.querySelectorAll('.chip-wrap.open').forEach(w => w.classList.remove('open'));\n});\n\n// ====== 筛选应用 ======\nconst NOW = new Date('2026-05-19'); // 当前日期参考 (Shell.today)\nfunction withinDate(dateStr) {\n if (state.date === 'all') return true;\n const days = { '7d': 7, '30d': 30, '90d': 90 }[state.date];\n if (!days) return true;\n const d = new Date(dateStr);\n const diffMs = NOW - d;\n return diffMs <= days * 24 * 60 * 60 * 1000;\n}\n\nfunction applyFilter() {\n let visible = 0;\n const visibleCards = [];\n cards.forEach(c => {\n const matchCat = state.cats.size === 0 || state.cats.has(c.dataset.cat);\n const q = state.search.toLowerCase();\n const matchSearch = !q\n || (c.dataset.name || '').toLowerCase().includes(q)\n || (c.dataset.tags || '').toLowerCase().includes(q)\n || (c.dataset.cat || '').toLowerCase().includes(q);\n const matchDate = withinDate(c.dataset.date);\n const show = matchCat && matchSearch && matchDate;\n c.style.display = show ? '' : 'none';\n if (show) { visible++; visibleCards.push(c); }\n });\n\n // 默认按 added desc(最新在前)\n visibleCards.sort((a, b) => +b.dataset.added - +a.dataset.added);\n visibleCards.forEach(c => grid.appendChild(c));\n\n // 分页 · 排序后裁页, 把非当前页的卡片隐藏\n const totalPages = Math.max(1, Math.ceil(visible / state.pageSize));\n if (state.page > totalPages) state.page = totalPages;\n if (state.page < 1) state.page = 1;\n const start = (state.page - 1) * state.pageSize;\n const end = start + state.pageSize;\n visibleCards.forEach((c, i) => {\n if (i < start || i >= end) c.style.display = 'none';\n });\n\n const empty = document.getElementById('empty');\n if (visible === 0) {\n empty.classList.add('show');\n grid.style.display = 'none';\n } else {\n empty.classList.remove('show');\n grid.style.display = '';\n }\n\n document.getElementById('result-meta').innerHTML = `// 显示 <span class=\"count\">${visible}</span> / ${TOTAL} 个商品`;\n document.getElementById('clear-filters').hidden = !(state.cats.size > 0 || state.search || state.date !== 'all');\n\n renderPagination(visible, totalPages);\n}\n\n// ============== 分页器渲染 ==============\nfunction pageNumberList(cur, total) {\n if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);\n const pages = new Set([1, total, cur, cur - 1, cur + 1]);\n if (cur <= 4) [2, 3, 4, 5].forEach(p => pages.add(p));\n if (cur >= total - 3) [total - 4, total - 3, total - 2, total - 1].forEach(p => pages.add(p));\n const sorted = [...pages].filter(p => p >= 1 && p <= total).sort((a, b) => a - b);\n const out = [];\n for (let i = 0; i < sorted.length; i++) {\n if (i > 0 && sorted[i] - sorted[i - 1] > 1) out.push('…');\n out.push(sorted[i]);\n }\n return out;\n}\n\nfunction renderPagination(totalVisible, totalPages) {\n const root = document.getElementById('pagination');\n if (!root) return;\n // 空结果或只有一页且 ≤ pageSize → 不显示\n if (totalVisible === 0 || (totalPages <= 1 && totalVisible <= state.pageSize)) {\n root.hidden = true;\n return;\n }\n root.hidden = false;\n document.getElementById('page-total').textContent = totalVisible;\n document.getElementById('page-size-label').textContent = `${state.pageSize} 条/页`;\n document.getElementById('page-jump').value = state.page;\n document.getElementById('page-jump').max = totalPages;\n\n const list = document.getElementById('page-list');\n const items = pageNumberList(state.page, totalPages);\n let html = `<button type=\"button\" data-page=\"prev\" ${state.page <= 1 ? 'disabled' : ''} aria-label=\"上一页\">\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M10 12L6 8l4-4\"/></svg>\n </button>`;\n items.forEach(p => {\n if (p === '…') html += `<span class=\"ellipsis\">…</span>`;\n else html += `<button type=\"button\" data-page=\"${p}\" ${p === state.page ? 'class=\"active\"' : ''}>${p}</button>`;\n });\n html += `<button type=\"button\" data-page=\"next\" ${state.page >= totalPages ? 'disabled' : ''} aria-label=\"下一页\">\n <svg width=\"11\" height=\"11\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6 4l4 4-4 4\"/></svg>\n </button>`;\n list.innerHTML = html;\n}\n\n// 搜索\ndocument.getElementById('search-input').addEventListener('input', e => {\n state.search = e.target.value.trim();\n state.page = 1;\n applyFilter();\n});\n\n// 清空筛选\ndocument.getElementById('clear-filters').addEventListener('click', () => {\n state.cats.clear();\n state.search = '';\n state.date = 'all';\n document.getElementById('search-input').value = '';\n renderCatMenu();\n syncCatChip();\n syncDateChip();\n state.page = 1;\n applyFilter();\n Shell.toast('已清空筛选');\n});\n\n// ============== 分页器控件 ==============\n// 翻页按钮(事件委托)\ndocument.getElementById('page-list').addEventListener('click', e => {\n const btn = e.target.closest('button[data-page]');\n if (!btn || btn.disabled) return;\n const v = btn.dataset.page;\n const totalPages = +document.getElementById('page-jump').max || 1;\n if (v === 'prev') state.page = Math.max(1, state.page - 1);\n else if (v === 'next') state.page = Math.min(totalPages, state.page + 1);\n else state.page = +v;\n applyFilter();\n // 翻页后把网格滚回顶, 让首张卡立刻可见\n const wrap = document.querySelector('.product-grid-wrap');\n if (wrap) wrap.scrollTo({ top: 0, behavior: 'smooth' });\n});\n\n// 每页条数(循环切换 12 → 24 → 48 → 96 → 12)\ndocument.getElementById('page-size-btn').addEventListener('click', () => {\n const i = PAGE_SIZES.indexOf(state.pageSize);\n state.pageSize = PAGE_SIZES[(i + 1) % PAGE_SIZES.length];\n state.page = 1;\n applyFilter();\n});\n\n// 跳转\nconst _jumpEl = document.getElementById('page-jump');\nfunction _doJump() {\n let v = parseInt(_jumpEl.value, 10);\n const max = +_jumpEl.max || 1;\n if (!Number.isFinite(v)) v = 1;\n v = Math.max(1, Math.min(max, v));\n state.page = v;\n applyFilter();\n}\n_jumpEl.addEventListener('change', _doJump);\n_jumpEl.addEventListener('blur', _doJump);\n_jumpEl.addEventListener('keydown', e => {\n if (e.key === 'Enter') { e.preventDefault(); _doJump(); _jumpEl.blur(); }\n});\n\napplyFilter();\n\n// ============================================================\n// 新建商品 · Drawer 控制 + 多图上传 + 卖点 bullet-list\n// ============================================================\nconst drawerBg = document.getElementById('pc-drawer-bg');\nconst drawerEl = document.getElementById('pc-drawer');\nconst openBtn = document.getElementById('open-new-product');\nconst closeBtn = document.getElementById('pc-drawer-close');\nconst cancelBtn = document.getElementById('pc-cancel-btn');\nconst saveBtn = document.getElementById('pc-save-btn');\n\nfunction openNewProductDrawer() {\n if (drawerEl.classList.contains('show')) return;\n drawerBg.classList.add('show');\n drawerEl.classList.add('show');\n drawerEl.setAttribute('aria-hidden', 'false');\n if (typeof Shell !== 'undefined' && Shell.lockScroll) Shell.lockScroll();\n setTimeout(() => document.getElementById('pf-name')?.focus(), 280);\n}\nfunction closeNewProductDrawer() {\n if (!drawerEl.classList.contains('show')) return;\n drawerBg.classList.remove('show');\n drawerEl.classList.remove('show');\n drawerEl.setAttribute('aria-hidden', 'true');\n if (typeof Shell !== 'undefined' && Shell.unlockScroll) Shell.unlockScroll();\n}\n\nopenBtn.addEventListener('click', openNewProductDrawer);\ncloseBtn.addEventListener('click', closeNewProductDrawer);\ncancelBtn.addEventListener('click', closeNewProductDrawer);\ndrawerBg.addEventListener('click', closeNewProductDrawer);\ndocument.addEventListener('keydown', e => {\n if (e.key === 'Escape' && drawerEl.classList.contains('show')) closeNewProductDrawer();\n});\n\n// 来自其它页面的\"新建商品\"链接 → 跳到 products.html 后自动开\nif (sessionStorage.getItem('auto-open-new-product') === '1') {\n sessionStorage.removeItem('auto-open-new-product');\n setTimeout(openNewProductDrawer, 100);\n}\n\n// ─── 多图上传 ───\nconst PF_MAX = 5;\nconst pfFiles = []; // {id, dataUrl, name}\nconst pfFile = document.getElementById('pf-file');\nconst pfZone = document.getElementById('pf-zone');\nconst pfGrid = document.getElementById('pf-grid');\n\nconst pfUid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 5);\nconst pfEsc = s => (s || '').replace(/[<>&\"]/g, c => ({ '<':'<','>':'>','&':'&','\"':'"' })[c]);\n\nfunction pfRender() {\n pfGrid.innerHTML = pfFiles.map(u => `\n <div class=\"pf-thumb\" data-id=\"${u.id}\">\n <img src=\"${u.dataUrl}\" alt=\"${pfEsc(u.name)}\">\n <button class=\"pf-x\" type=\"button\" title=\"删除\">\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"><path d=\"M4 4l8 8M12 4l-8 8\"/></svg>\n </button>\n </div>\n `).join('');\n pfGrid.querySelectorAll('.pf-thumb .pf-x').forEach(b => {\n b.onclick = e => {\n e.stopPropagation();\n const id = b.closest('.pf-thumb').dataset.id;\n const i = pfFiles.findIndex(f => f.id === id);\n if (i >= 0) { pfFiles.splice(i, 1); pfRender(); }\n };\n });\n}\n\nfunction pfAdd(fileList) {\n const room = PF_MAX - pfFiles.length;\n if (room <= 0) { Shell.toast('已达上限', `${PF_MAX} / ${PF_MAX} 张`); return; }\n const incoming = [...fileList].filter(f => f.type.startsWith('image/')).slice(0, room);\n let done = 0;\n incoming.forEach(f => {\n const r = new FileReader();\n r.onload = e => {\n pfFiles.push({ id: pfUid(), dataUrl: e.target.result, name: f.name });\n if (++done === incoming.length) {\n pfRender();\n Shell.toast('已上传', `+ ${done} 张 · 共 ${pfFiles.length} / ${PF_MAX}`);\n }\n };\n r.readAsDataURL(f);\n });\n}\n\npfFile.addEventListener('change', e => { pfAdd(e.target.files); e.target.value = ''; });\npfZone.addEventListener('click', () => { if (pfFiles.length < PF_MAX) pfFile.click(); });\npfZone.addEventListener('dragover', e => { e.preventDefault(); pfZone.style.borderColor = 'var(--heat)'; });\npfZone.addEventListener('dragleave', () => { pfZone.style.borderColor = ''; });\npfZone.addEventListener('drop', e => {\n e.preventDefault(); pfZone.style.borderColor = '';\n if (e.dataTransfer?.files?.length) pfAdd(e.dataTransfer.files);\n});\n\n// ─── 核心卖点 bullet-list ───\nconst blList = document.getElementById('pf-bullets');\nconst blInput = blList.querySelector('.bl-add .bl-input');\nconst blXSvg = '<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"><path d=\"M4 4l8 8M12 4l-8 8\"/></svg>';\n\nfunction blRenumber() {\n [...blList.querySelectorAll('.bl-item')].forEach((li, i) => {\n li.querySelector('.num').textContent = i + 1;\n });\n}\nfunction blBindX(x) {\n x.addEventListener('click', () => {\n const li = x.closest('li');\n li.style.transition = 'opacity .15s, transform .15s';\n li.style.opacity = 0;\n li.style.transform = 'translateX(-8px)';\n setTimeout(() => { li.remove(); blRenumber(); }, 150);\n });\n}\nfunction blAdd(text) {\n const t = (text || '').trim();\n if (!t) return;\n const li = document.createElement('li');\n li.className = 'bl-item';\n li.innerHTML = `<span class=\"num\">0</span><span class=\"bl-text\">${pfEsc(t)}</span><span class=\"bl-x\" title=\"删除\">${blXSvg}</span>`;\n blList.querySelector('.bl-add').before(li);\n blBindX(li.querySelector('.bl-x'));\n blRenumber();\n}\nblInput.addEventListener('keydown', e => {\n if (e.key === 'Enter') {\n e.preventDefault();\n blAdd(blInput.value);\n blInput.value = '';\n }\n});\n\n// 创建商品 · 持久化到 localStorage + 立即跳详情(中间不闪商品库)\nsaveBtn.addEventListener('click', () => {\n const name = document.getElementById('pf-name').value.trim();\n const cat = document.getElementById('pf-cat').value;\n if (!name) {\n Shell.toast('请填写商品名称', '必填项');\n document.getElementById('pf-name').focus();\n return;\n }\n if (pfFiles.length === 0) {\n Shell.toast('请上传商品主图', '至少 1 张 · 必填');\n return;\n }\n const confirmedBullets = [...document.querySelectorAll('#pf-bullets .bl-item .bl-text')]\n .map(el => el.textContent.trim()).filter(Boolean);\n const pendingInput = document.querySelector('#pf-bullets .bl-add .bl-input');\n const pendingText = (pendingInput?.value || '').trim();\n if (pendingText) confirmedBullets.push(pendingText);\n if (confirmedBullets.length === 0) {\n Shell.toast('请填写核心卖点', '至少 1 条 · 回车确认');\n pendingInput?.focus();\n return;\n }\n const bullets = confirmedBullets;\n // 持久化到 sessionStorage('fs-extra-products') · 仅在当前标签页生命周期内有效\n // 关闭标签页/浏览器后自动清空,不会跨会话累积演示残留\n try {\n const KEY = 'fs-extra-products';\n const list = JSON.parse(sessionStorage.getItem(KEY) || '[]');\n list.push({\n id: 'pp-' + Date.now(),\n name, cat,\n tags: '',\n assets: 0,\n videos: 0,\n bullets,\n date: new Date().toISOString().slice(0, 10),\n createdAt: Date.now(),\n });\n sessionStorage.setItem(KEY, JSON.stringify(list));\n } catch (e) { /* storage 不可用降级到只跳转 */ }\n\n // 不 close drawer · 跳转期间 drawer 仍覆盖 host 页面 → 视觉上彻底消除\"闪商品库\"\n // 浏览器导航开始后,整页会被新页面替换,drawer 自然消失\n Shell.toast('商品已创建 · 跳转详情', `+ ${name} · ${bullets.length} 条卖点`);\n location.href = 'product-detail.html?t=' + Date.now() + '&product=' + encodeURIComponent(name) + '&id=new';\n});\n</script>\n<script src=\"/exact/assets/api-bridge.js?v=2026060114\"></script>\n</body>\n</html>\n",
|
||
"projectWizard": "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta name=\"x-airshelf-exact-source\" content=\"projects-new.html\">\n<meta charset=\"utf-8\">\n<title>新建项目 · Airshelf</title>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"/exact/assets/restraint.css?v=2026060101\">\n<style>\n .wizard { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 36px; align-items: start; max-width: 1400px; }\n @media (max-width: 1180px) { .wizard { grid-template-columns: 200px minmax(0, 1fr); } }\n .steps {\n position: sticky;\n top: calc(64px + 24px);\n align-self: start;\n max-height: calc(100vh - 64px - 48px);\n overflow-y: auto;\n z-index: 2;\n }\n .wiz-preview { display: none !important; }\n /* 顶部胶囊 + 商品横向单行 */\n .pick-actions { display: flex; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }\n .cap-pill { height: 30px; padding: 0 14px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: 999px; font-size: 12.5px; color: var(--accent-black); display: inline-flex; align-items: center; gap: 6px; cursor: pointer; font-family: inherit; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }\n .cap-pill:hover { background: var(--background-lighter); border-color: var(--heat-20); color: var(--heat); }\n .cap-pill svg { width: 13px; height: 13px; }\n .product-pick-row { display: flex; gap: 10px; overflow-x: auto; overflow-y: hidden; padding: 2px 2px 12px; scrollbar-width: thin; }\n .product-pick-row::-webkit-scrollbar { height: 8px; }\n .product-pick-row::-webkit-scrollbar-thumb { background: var(--border-faint); border-radius: 4px; }\n .product-pick-row .product-pick { flex: 0 0 240px; min-width: 240px; }\n .product-pick-row .product-pick.lib { flex: 0 0 152px; min-width: 152px; min-height: 96px; }\n /* 底部开始 CTA */\n .wiz-start-bar { display: flex; justify-content: flex-end; padding: 20px 0 8px; }\n .wiz-start-bar .btn-start { height: 44px; padding: 0 36px; background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: 999px; font-size: 14px; font-weight: 600; cursor: pointer; box-shadow: var(--shadow-cta); display: inline-flex; align-items: center; gap: 8px; font-family: inherit; transition: box-shadow var(--t-base), opacity var(--t-base); }\n .wiz-start-bar .btn-start:hover:not(.disabled) { box-shadow: var(--shadow-cta-hover); }\n .wiz-start-bar .btn-start.disabled { opacity: .4; cursor: not-allowed; }\n .wiz-start-bar .btn-start svg { width: 14px; height: 14px; }\n /* 单页式: 所有 step pane 同时显示, 不再切换 */\n #wiz-body { display: flex; flex-direction: column; gap: 14px; }\n .wiz-pane.active::before, .wiz-pane.active::after { display: none; } /* 去掉 corner-mark, 不需要 \"当前激活\" 视觉强突出 */\n .wiz-pane.active { padding: 22px 24px; }\n .wiz-foot { display: none !important; } /* 底部上一步/下一步彻底隐藏 */\n /* 计费明细 */\n .pv-list.pv-bill li {\n display: flex; justify-content: space-between;\n padding: 5px 0;\n font-size: 12px;\n color: var(--black-alpha-72);\n }\n .pv-list.pv-bill li .ai { color: var(--accent-black); font-family: var(--font-mono); font-size: 11.5px; }\n .pv-list.pv-bill .pv-bill-total {\n border-top: 1px solid var(--border-faint);\n margin-top: 6px; padding-top: 8px;\n font-weight: 600; color: var(--accent-black);\n }\n .pv-list.pv-bill .pv-bill-total .ai b { color: var(--heat); font-size: 14px; font-weight: 700; }\n .step { display: flex; gap: 12px; padding: 12px 0; position: relative; }\n .step:not(:last-child)::after { content: ''; position: absolute; left: 11px; top: 36px; width: 1px; height: calc(100% - 24px); background: var(--border-faint); }\n .step .num { width: 24px; height: 24px; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); display: grid; place-items: center; font-size: 11px; font-weight: 600; color: var(--black-alpha-48); flex-shrink: 0; z-index: 1; font-family: var(--font-mono); }\n .step.done .num { background: var(--accent-black); border-color: var(--accent-black); color: var(--accent-white); }\n .step.active .num { background: var(--heat); border-color: var(--heat); color: var(--accent-white); }\n .step .label { font-size: 13.5px; font-weight: 500; color: var(--black-alpha-56); padding-top: 2px; }\n .step .desc { font-size: 11.5px; color: var(--black-alpha-48); padding-top: 3px; line-height: 1.4; font-family: var(--font-mono); letter-spacing: .02em; }\n .step.active .label { color: var(--accent-black); font-weight: 600; }\n .step.done .label { color: var(--black-alpha-56); }\n .step.done:not(:last-child)::after { background: var(--accent-black); }\n .step.clickable { cursor: pointer; }\n .step.clickable:hover .label { color: var(--heat); }\n .step.clickable:hover .num { border-color: var(--heat); }\n\n .wiz-pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 22px 24px; margin-bottom: 14px; }\n .wiz-pane.active { padding: 26px 28px; position: relative; }\n .wiz-pane.active::before, .wiz-pane.active::after { content: ''; position: absolute; width: 14px; height: 14px; background: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 21' fill='%23e8e8e8'%3E%3Cpath d='M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z'/%3E%3C/svg%3E\") no-repeat center; background-size: contain; pointer-events: none; }\n .wiz-pane.active::before { top: -7px; left: -7px; }\n .wiz-pane.active::after { bottom: -7px; right: -7px; }\n .wiz-pane.collapsed { padding: 16px 20px; }\n .wiz-pane-h { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }\n .wiz-pane-h h3 { font-size: 14px; font-weight: 600; }\n .wiz-step-h { margin-bottom: 18px; }\n .wiz-step-h h2 { font-size: 20px; font-weight: 600; letter-spacing: -.015em; }\n .wiz-step-h p { font-size: 13px; color: var(--black-alpha-56); margin-top: 6px; }\n\n .opt-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }\n .opt-row.cols-4 { grid-template-columns: repeat(4, 1fr); }\n .opt-row.cols-6 { grid-template-columns: repeat(3, 1fr); }\n @media (min-width: 1280px) { .opt-row.cols-6 { grid-template-columns: repeat(6, 1fr); } }\n .opt-card { border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 14px; background: var(--surface); cursor: pointer; position: relative; display: flex; flex-direction: column; min-width: 0; transition: background var(--t-base), border-color var(--t-base); }\n .opt-card:hover { background: var(--background-lighter); }\n .opt-card.selected { border-color: var(--heat); background: var(--heat-12); }\n .opt-card.selected::after { content: ''; position: absolute; top: 8px; right: 10px; width: 16px; height: 16px; background-color: var(--heat); background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E\"); background-repeat: no-repeat; background-position: center; background-size: 10px 10px; border-radius: var(--r-sm); }\n .opt-card h4 { font-size: 13px; font-weight: 600; }\n .opt-card .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); margin-top: 3px; letter-spacing: .02em; }\n .opt-card .note { font-size: 11.5px; color: var(--black-alpha-56); margin-top: 6px; line-height: 1.5; }\n .opt-card .metric { margin-top: auto; padding-top: 10px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .opt-card .metric .val { color: var(--accent-black); font-weight: 500; }\n .opt-card.selected .metric .val { color: var(--heat); }\n .opt-card .badge { font-family: var(--font-mono); font-size: 9.5px; padding: 1px 6px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-48); display: inline-block; margin-top: 8px; letter-spacing: .04em; align-self: flex-start; }\n .opt-card.selected .badge { color: var(--heat); border-color: var(--heat-20); }\n\n .theme-pill-row { display: flex; gap: 8px; flex-wrap: wrap; }\n .theme-pill { display: inline-flex; gap: 6px; align-items: center; height: 36px; padding: 0 16px; border: 1px solid var(--border-faint); border-radius: 999px; background: var(--surface); font-size: 13px; font-weight: 500; font-family: inherit; cursor: pointer; color: var(--accent-black); transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }\n .theme-pill:hover { background: var(--background-lighter); }\n .theme-pill.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat); font-weight: 600; }\n .theme-pill svg { width: 13px; height: 13px; }\n\n .reco-bubble { position: relative; margin-top: 10px; padding: 10px 14px; background: var(--heat-12); border: 1px solid var(--heat-20); border-radius: var(--r-md); display: flex; align-items: center; gap: 12px; font-size: 12.5px; color: var(--accent-black); }\n .reco-bubble::before { content: ''; position: absolute; top: -5px; left: 28px; width: 9px; height: 9px; background: var(--heat-12); border-left: 1px solid var(--heat-20); border-top: 1px solid var(--heat-20); transform: rotate(45deg); }\n .reco-bubble .ic { color: var(--heat); flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; }\n .reco-bubble .ic svg, .reco-bubble .dismiss svg { display: block; }\n .reco-bubble .txt { flex: 1; line-height: 1.5; }\n .reco-bubble .txt strong { color: var(--heat); font-weight: 600; }\n .reco-bubble .txt .meta { display: block; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); margin-top: 2px; letter-spacing: .02em; }\n .reco-bubble .btn-apply { height: 28px; padding: 0 12px; background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: var(--r-md); font-size: 12px; font-weight: 600; cursor: pointer; flex-shrink: 0; box-shadow: var(--shadow-cta); transition: box-shadow var(--t-base); }\n .reco-bubble .btn-apply:hover { box-shadow: var(--shadow-cta-hover); }\n .reco-bubble .dismiss { background: transparent; color: var(--black-alpha-48); border: 0; width: 24px; height: 24px; padding: 0; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }\n .reco-bubble .dismiss:hover { color: var(--accent-black); }\n\n .wiz-foot { display: flex; justify-content: space-between; align-items: center; margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--border-faint); }\n .btn:disabled, .btn.disabled { opacity: .45; cursor: not-allowed; pointer-events: none; }\n\n /* ── pick toolbar (Step 1) ── */\n .pick-toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }\n .pick-toolbar .search-input { position: relative; flex: 1; max-width: 320px; min-width: 200px; }\n .pick-toolbar .search-input svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--black-alpha-48); width: 14px; height: 14px; }\n .pick-toolbar .search-input input { width: 100%; height: 32px; padding: 0 12px 0 34px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); font-size: 12.5px; color: var(--accent-black); font-family: inherit; transition: border-color var(--t-base); }\n .pick-toolbar .search-input input:focus { outline: none; border-color: var(--heat); }\n .cat-chip { height: 32px; padding: 0 12px; border: 1px solid var(--border-faint); background: var(--surface); border-radius: var(--r-md); font-size: 12.5px; color: var(--black-alpha-56); font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; font-family: inherit; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }\n .cat-chip:hover { background: var(--background-lighter); }\n .cat-chip.active { border-color: var(--heat); color: var(--heat); background: var(--heat-12); }\n\n .pick-section-h { display: flex; align-items: baseline; gap: 8px; margin: 14px 0 8px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .08em; text-transform: uppercase; }\n .pick-section-h .count { background: var(--background-lighter); border: 1px solid var(--border-faint); padding: 1px 6px; color: var(--black-alpha-48); font-size: 10px; }\n\n /* ============================================================\n 第 1 步 · 商品选择器(沿用 products.html 商品库的卡片与 toolbar 视觉)\n ─ 命名空间 .pp- 前缀,避免与 products.html 冲突\n ============================================================ */\n .pp-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }\n .pp-toolbar .search-inline {\n flex: 1; min-width: 220px; max-width: 340px;\n display: inline-flex; align-items: center; gap: 8px;\n height: 34px; padding: 0 12px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n transition: border-color var(--t-base);\n }\n .pp-toolbar .search-inline:focus-within { border-color: var(--heat-40); }\n .pp-toolbar .search-inline svg { width: 14px; height: 14px; color: var(--black-alpha-48); flex-shrink: 0; }\n .pp-toolbar .search-inline input { flex: 1; min-width: 0; height: 100%; border: 0; outline: 0; background: transparent; font-size: 13px; color: var(--accent-black); font-family: inherit; }\n .pp-toolbar .pp-chip-wrap { position: relative; }\n .pp-toolbar .pp-chip {\n display: inline-flex; align-items: center; gap: 6px;\n height: 34px; padding: 0 12px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n font-size: 13px; font-family: inherit;\n color: var(--black-alpha-72);\n cursor: pointer;\n transition: border-color var(--t-base), color var(--t-base);\n }\n .pp-toolbar .pp-chip:hover { border-color: var(--black-alpha-32); color: var(--accent-black); }\n .pp-toolbar .pp-chip.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); }\n .pp-toolbar .pp-chip svg { width: 11px; height: 11px; opacity: .6; }\n .pp-toolbar .pp-menu {\n position: absolute; top: calc(100% + 4px); left: 0;\n min-width: 160px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n box-shadow: 0 6px 20px rgba(0,0,0,.06);\n padding: 4px;\n display: none;\n z-index: 20;\n }\n .pp-toolbar .pp-chip-wrap.open .pp-menu { display: block; }\n .pp-toolbar .pp-menu .mi {\n display: flex; align-items: center; gap: 6px;\n padding: 7px 10px;\n border-radius: var(--r-sm);\n font-size: 12.5px;\n color: var(--accent-black);\n cursor: pointer;\n }\n .pp-toolbar .pp-menu .mi:hover { background: var(--background-lighter); }\n .pp-toolbar .pp-menu .mi.selected { color: var(--heat); font-weight: 600; }\n .pp-toolbar .pp-menu .mi-check { width: 12px; height: 12px; opacity: 0; }\n .pp-toolbar .pp-menu .mi.selected .mi-check { opacity: 1; }\n .pp-toolbar .pp-clear {\n display: inline-flex; align-items: center; gap: 4px;\n height: 30px; padding: 0 10px;\n background: transparent; border: 0; border-radius: var(--r-sm);\n color: var(--black-alpha-56); font-size: 12.5px; font-family: inherit;\n cursor: pointer;\n }\n .pp-toolbar .pp-clear:hover { color: var(--accent-crimson); background: var(--crimson-bg, #fdebea); }\n .pp-toolbar .pp-clear svg { width: 11px; height: 11px; }\n .pp-toolbar .spacer { flex: 1; }\n .pp-view-tog { display: inline-flex; border: 1px solid var(--border-faint); border-radius: var(--r-md); overflow: hidden; }\n .pp-view-tog button {\n width: 34px; height: 34px;\n background: var(--surface);\n border: 0; border-right: 1px solid var(--border-faint);\n cursor: pointer;\n color: var(--black-alpha-48);\n display: grid; place-items: center;\n transition: background var(--t-base), color var(--t-base);\n }\n .pp-view-tog button:last-child { border-right: 0; }\n .pp-view-tog button:hover { background: var(--background-lighter); color: var(--accent-black); }\n .pp-view-tog button.active { background: var(--heat-12); color: var(--heat); }\n .pp-view-tog button svg { width: 14px; height: 14px; }\n\n .pp-result-meta {\n font-family: var(--font-mono); font-size: 11.5px;\n color: var(--black-alpha-48); letter-spacing: .02em;\n margin: 4px 0 12px;\n }\n\n /* 网格 — 沿用 products.html 商品库卡片样式(复制必要部分)\n 固定 4 列 → 每页 8 tile(createCard + 7 商品)正好两行 */\n .pp-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }\n @media (max-width: 1100px) { .pp-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }\n @media (max-width: 800px) { .pp-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }\n .pp-grid .product-card {\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n cursor: pointer; position: relative; overflow: hidden;\n display: flex; flex-direction: column;\n transition: background .15s, border-color .15s, transform .15s;\n }\n .pp-grid .product-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }\n .pp-grid .product-card.selected { border-color: var(--heat); background: var(--heat-12); }\n .pp-grid .product-card.selected::after {\n content: ''; position: absolute; top: 0; right: 0;\n width: 0; height: 0;\n border-top: 28px solid var(--heat);\n border-left: 28px solid transparent;\n z-index: 2;\n }\n .pp-grid .product-card.selected::before {\n content: ''; position: absolute; top: 4px; right: 4px;\n width: 10px; height: 10px;\n background: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23ffffff' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 8 7 12 13 4'/%3E%3C/svg%3E\") no-repeat center / contain;\n z-index: 3;\n }\n .pp-grid .product-thumb { aspect-ratio: 1.4 / 1; }\n .pp-grid .product-body { padding: 14px 14px 12px; flex: 1; }\n .pp-grid .product-name {\n font-size: 14px; font-weight: 600; color: var(--accent-black);\n line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n }\n .pp-grid .product-cat {\n display: inline-flex; align-items: center;\n margin-top: 8px; padding: 2px 8px;\n background: var(--background-lighter); color: var(--black-alpha-72);\n border-radius: var(--r-sm); font-size: 11.5px;\n }\n .pp-grid .product-date {\n font-family: var(--font-mono);\n font-size: 11px; color: var(--black-alpha-48);\n margin-top: 10px; letter-spacing: .02em;\n }\n .pp-grid .product-card.selected .product-cat { background: var(--surface); color: var(--heat); }\n\n /* 创建新商品 空卡 */\n .pp-grid .pp-create-card {\n border: 1.5px dashed var(--black-alpha-24);\n border-radius: var(--r-md);\n background: transparent;\n cursor: pointer;\n display: flex; flex-direction: column; align-items: center; justify-content: center;\n gap: 10px; min-height: 220px;\n color: var(--black-alpha-48);\n transition: border-color var(--t-base), color var(--t-base), background var(--t-base);\n }\n .pp-grid .pp-create-card:hover { border-color: var(--heat); color: var(--heat); background: var(--heat-12); }\n .pp-grid .pp-create-card .pc-plus {\n width: 44px; height: 44px;\n border-radius: 50%;\n background: var(--heat); color: var(--accent-white);\n display: grid; place-items: center;\n transition: filter var(--t-base);\n }\n .pp-grid .pp-create-card:hover .pc-plus { filter: brightness(1.06); }\n .pp-grid .pp-create-card .pc-plus svg { width: 18px; height: 18px; }\n .pp-grid .pp-create-card .pc-t { font-size: 13px; font-weight: 600; }\n .pp-grid .pp-create-card .pc-d { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .02em; }\n\n /* 列表视图 */\n .pp-grid.list-view { display: flex; flex-direction: column; gap: 6px; }\n .pp-grid.list-view .product-card {\n flex-direction: row; align-items: center;\n }\n .pp-grid.list-view .product-thumb { width: 96px; aspect-ratio: 1.4 / 1; flex-shrink: 0; }\n .pp-grid.list-view .product-body { flex: 1; padding: 10px 14px; }\n .pp-grid.list-view .pp-create-card { flex-direction: row; min-height: 56px; gap: 12px; }\n .pp-grid.list-view .pp-create-card .pc-plus { width: 32px; height: 32px; }\n\n /* 空筛选结果 */\n .pp-empty {\n grid-column: 1 / -1;\n padding: 48px 24px; text-align: center;\n border: 1px dashed var(--border-faint);\n border-radius: var(--r-md);\n background: var(--background-lighter);\n color: var(--black-alpha-48);\n font-size: 12.5px; font-family: var(--font-mono); letter-spacing: .02em;\n }\n .pp-empty .reset { display: inline-block; margin-top: 8px; color: var(--heat); cursor: pointer; text-decoration: underline; }\n\n /* 分页 */\n .pp-pager {\n display: flex; align-items: center; gap: 16px;\n margin-top: 18px; padding-top: 14px;\n border-top: 1px solid var(--border-faint);\n font-size: 12.5px; color: var(--black-alpha-56);\n }\n .pp-pager .total { font-family: var(--font-mono); letter-spacing: .02em; }\n .pp-pager .pages { display: inline-flex; gap: 4px; margin-left: auto; }\n .pp-pager .pages button {\n min-width: 28px; height: 28px; padding: 0 8px;\n border: 1px solid var(--border-faint); background: var(--surface);\n border-radius: var(--r-sm);\n cursor: pointer; font-size: 12.5px; color: var(--black-alpha-72); font-family: inherit;\n transition: border-color var(--t-base), background var(--t-base), color var(--t-base);\n }\n .pp-pager .pages button:hover:not(.active):not(:disabled) { border-color: var(--black-alpha-32); color: var(--accent-black); }\n .pp-pager .pages button.active { background: var(--heat); color: var(--accent-white); border-color: var(--heat); font-weight: 600; }\n .pp-pager .pages button:disabled { opacity: .4; cursor: not-allowed; }\n .pp-pager .pages .ellipsis {\n min-width: 22px; height: 28px;\n display: inline-flex; align-items: center; justify-content: center;\n color: var(--black-alpha-48); font-family: var(--font-mono);\n }\n .pp-pager .page-size {\n display: inline-flex; align-items: center; gap: 4px;\n height: 28px; padding: 0 10px;\n background: var(--surface); border: 1px solid var(--border-faint);\n border-radius: var(--r-sm);\n cursor: pointer; font-family: inherit; font-size: 12.5px; color: var(--black-alpha-72);\n }\n .pp-pager .page-size:hover { border-color: var(--black-alpha-32); color: var(--accent-black); }\n .pp-pager .page-size svg { width: 10px; height: 10px; opacity: .6; }\n\n /* 底部提示 */\n .pp-bottom-tip {\n margin-top: 14px;\n padding: 10px 14px;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n font-size: 12.5px; color: var(--black-alpha-56);\n display: flex; align-items: center; gap: 8px;\n }\n .pp-bottom-tip svg { width: 14px; height: 14px; flex-shrink: 0; color: var(--black-alpha-48); }\n .pp-bottom-tip a { color: var(--heat); cursor: pointer; text-decoration: none; }\n .pp-bottom-tip a:hover { text-decoration: underline; }\n\n /* ── Step 1 · product picker grid ── */\n .product-pick-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }\n @media (max-width: 1100px) { .product-pick-grid { grid-template-columns: repeat(2, 1fr); } }\n .product-pick { display: flex; gap: 12px; padding: 12px; border: 1px solid var(--border-faint); border-radius: var(--r-md); background: var(--surface); cursor: pointer; position: relative; transition: background var(--t-base), border-color var(--t-base); min-width: 0; }\n .product-pick:hover { background: var(--background-lighter); }\n .product-pick.selected { border-color: var(--heat); background: var(--heat-12); }\n .product-pick.selected::after { content: ''; position: absolute; top: 8px; right: 10px; width: 16px; height: 16px; background-color: var(--heat); background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E\"); background-repeat: no-repeat; background-position: center; background-size: 10px 10px; border-radius: var(--r-sm); }\n .product-pick .thumb { width: 56px; height: 72px; flex-shrink: 0; }\n .product-pick .body { flex: 1; min-width: 0; padding-right: 18px; }\n .product-pick .name { font-weight: 600; font-size: 13px; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }\n .product-pick .meta { margin-top: 4px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .product-pick .meta b { color: var(--accent-black); font-weight: 500; }\n .product-pick .tags { margin-top: 6px; display: flex; gap: 4px; flex-wrap: wrap; }\n .product-pick .tag-s { font-size: 10.5px; color: var(--black-alpha-56); background: var(--background-lighter); padding: 1px 6px; border: 1px solid var(--border-faint); border-radius: var(--r-sm); }\n .product-pick.selected .tag-s { background: var(--surface); border-color: var(--heat-20); color: var(--heat); }\n\n .product-pick.add { display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 6px; border-style: dashed; color: var(--black-alpha-48); min-height: 96px; }\n .product-pick.add:hover { color: var(--heat); border-color: var(--heat); background: var(--heat-12); }\n .product-pick.add .pc { width: 32px; height: 32px; border: 1px solid currentColor; display: grid; place-items: center; border-radius: var(--r-sm); }\n .product-pick.add svg { width: 16px; height: 16px; }\n .product-pick.add .hint { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .product-pick.add.lib:hover { color: var(--heat); border-color: var(--heat); background: var(--heat-12); }\n\n /* ===== 商品库全屏弹窗 (单选,与 model-photo/platform-cover 风格统一) ===== */\n .pl-modal-bg { position: fixed; inset: 0; background: var(--surface); z-index: 998; display: none; }\n .pl-modal-bg.show { display: flex; }\n .pl-modal { margin: 0; flex: 1; background: var(--surface); overflow: hidden; display: flex; flex-direction: column; }\n .pl-modal-h { display: flex; align-items: center; gap: 14px; padding: 14px 28px; border-bottom: 1px solid var(--border-faint); flex-shrink: 0; }\n .pl-modal-h h2 { font-size: 16px; font-weight: 600; }\n .pl-modal-h .ct { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .pl-modal-h .actions { margin-left: auto; display: flex; gap: 10px; }\n .pl-modal-h .x { width: 32px; height: 32px; display: grid; place-items: center; background: transparent; border: 0; border-radius: var(--r-sm); cursor: pointer; color: var(--black-alpha-56); }\n .pl-modal-h .x:hover { background: var(--black-alpha-8); color: var(--accent-black); }\n .pl-modal-body { flex: 1; min-height: 0; display: grid; grid-template-columns: 200px 1fr; }\n .pl-side { border-right: 1px solid var(--border-faint); padding: 18px 0; overflow-y: auto; }\n .pl-side .pl-side-h { padding: 0 20px 8px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; }\n .pl-side .pl-side-item { display: flex; align-items: center; gap: 8px; padding: 9px 20px; cursor: pointer; color: var(--black-alpha-72); font-size: 13px; border-left: 3px solid transparent; transition: background var(--t-base), color var(--t-base); }\n .pl-side .pl-side-item:hover { background: var(--black-alpha-4); }\n .pl-side .pl-side-item.active { background: var(--heat-12); color: var(--accent-black); border-left-color: var(--heat); font-weight: 600; }\n .pl-side .pl-side-item .ct { margin-left: auto; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); }\n .pl-main { overflow-y: auto; padding: 0; display: flex; flex-direction: column; }\n .pl-toolbar { padding: 14px 28px; border-bottom: 1px solid var(--border-faint); display: flex; align-items: center; gap: 12px; flex-shrink: 0; }\n .pl-toolbar .search { position: relative; flex: 1; max-width: 360px; }\n .pl-toolbar .search input { width: 100%; height: 32px; padding: 0 10px 0 32px; background: var(--background-lighter); border: 1px solid var(--black-alpha-12); border-radius: var(--r-sm); font-size: 12.5px; font-family: inherit; color: var(--accent-black); outline: none; box-sizing: border-box; }\n .pl-toolbar .search svg { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); width: 14px; height: 14px; color: var(--black-alpha-48); }\n .pl-toolbar .btn-new { height: 32px; padding: 0 14px; display: inline-flex; align-items: center; gap: 6px; background: var(--surface); border: 1px solid var(--black-alpha-12); border-radius: var(--r-sm); color: var(--accent-black); font-family: inherit; font-size: 12.5px; cursor: pointer; margin-left: auto; }\n .pl-toolbar .btn-new:hover { background: var(--background-lighter); border-color: var(--black-alpha-24); }\n .pl-toolbar .btn-new svg { width: 13px; height: 13px; }\n .pl-scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 20px 28px 28px; }\n .pl-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }\n .pl-card { position: relative; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 10px; cursor: pointer; display: flex; flex-direction: column; gap: 6px; transition: background var(--t-base), border-color var(--t-base); }\n .pl-card:hover { background: var(--surface); }\n .pl-card.selected { border-color: var(--heat); background: var(--heat-12); }\n .pl-card .pl-thumb { aspect-ratio: 1; border-radius: var(--r-sm); }\n .pl-card .pl-name { font-size: 12.5px; font-weight: 500; color: var(--accent-black); }\n .pl-card .pl-meta { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .pl-card .pl-check { position: absolute; top: 16px; right: 16px; width: 22px; height: 22px; background: rgba(255,255,255,.95); border: 1.5px solid var(--black-alpha-24); border-radius: 50%; display: grid; place-items: center; z-index: 2; color: var(--accent-white); }\n .pl-card .pl-check svg { width: 11px; height: 11px; opacity: 0; }\n .pl-card.selected .pl-check { background: var(--heat); border-color: var(--heat); }\n .pl-card.selected .pl-check svg { opacity: 1; }\n .pl-modal-f { padding: 14px 28px; border-top: 1px solid var(--border-faint); display: flex; justify-content: flex-end; align-items: center; gap: 10px; flex-shrink: 0; }\n .pl-modal-f .summary { margin-right: auto; font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-56); letter-spacing: .02em; }\n .pl-modal-f .summary b { color: var(--heat); font-weight: 700; }\n\n /* ── Step 2 · source-type cards ── */\n .source-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }\n .source-card { display: flex; flex-direction: column; gap: 8px; padding: 16px 16px 14px; border: 1px solid var(--border-faint); border-radius: var(--r-md); background: var(--surface); cursor: pointer; position: relative; transition: background var(--t-base), border-color var(--t-base); min-height: 132px; }\n .source-card:hover { background: var(--background-lighter); }\n .source-card.selected { border-color: var(--heat); background: var(--heat-12); }\n .source-card.selected::after { content: ''; position: absolute; top: 10px; right: 12px; width: 16px; height: 16px; background-color: var(--heat); background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E\"); background-repeat: no-repeat; background-position: center; background-size: 10px 10px; border-radius: var(--r-sm); }\n .source-card .src-ic { width: 32px; height: 32px; background: var(--background-lighter); color: var(--black-alpha-56); border: 1px solid var(--border-faint); border-radius: var(--r-sm); display: grid; place-items: center; }\n .source-card .src-ic svg { width: 16px; height: 16px; }\n .source-card.selected .src-ic { background: var(--surface); color: var(--heat); border-color: var(--heat-20); }\n .source-card h4 { font-size: 14px; font-weight: 600; }\n .source-card .src-tag { font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-48); letter-spacing: .06em; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 1px 6px; align-self: flex-start; }\n .source-card.selected .src-tag { color: var(--heat); border-color: var(--heat-20); }\n .source-card .src-desc { font-size: 12px; color: var(--black-alpha-56); line-height: 1.55; margin-top: auto; }\n\n .source-detail { margin-top: 16px; padding: 18px 20px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); }\n .source-detail .sd-h { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .08em; text-transform: uppercase; margin-bottom: 10px; }\n .source-detail .sd-h b { color: var(--accent-black); font-weight: 500; }\n\n /* ── shared field styles ── */\n .field { display: block; margin-bottom: 16px; }\n .config-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; align-items: end; margin-bottom: 16px; }\n .config-row .field { margin-bottom: 0; }\n .duration-select { cursor: pointer; }\n @media (max-width: 900px) { .config-row { grid-template-columns: 1fr; } }\n .field-label { display: block; font-size: 12.5px; color: var(--black-alpha-56); font-weight: 500; margin-bottom: 6px; }\n .field-label .req { color: var(--heat); margin-left: 2px; }\n .field-hint { font-size: 11.5px; color: var(--black-alpha-48); margin-top: 4px; }\n .input, .textarea { width: 100%; height: 36px; padding: 0 12px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); font-size: 13px; color: var(--accent-black); font-family: inherit; transition: border-color var(--t-base); }\n .input:focus, .textarea:focus { outline: none; border-color: var(--heat); }\n .textarea { height: auto; padding: 10px 12px; resize: vertical; min-height: 120px; line-height: 1.55; }\n\n /* ── Step 4 · confirm grid / billing / balance / eta / tos ── */\n .confirm-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 18px; }\n .confirm-card { position: relative; border: 1px solid var(--border-faint); border-radius: var(--r-md); background: var(--surface); padding: 14px 16px; }\n .confirm-card .cc-h { display: flex; align-items: center; justify-content: space-between; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .08em; text-transform: uppercase; margin-bottom: 10px; }\n .confirm-card .cc-edit { font-size: 11.5px; color: var(--black-alpha-56); letter-spacing: 0; font-family: var(--font-sans, 'Inter'); text-transform: none; padding: 2px 8px; border: 1px solid var(--border-faint); background: var(--surface); cursor: pointer; border-radius: var(--r-sm); }\n .confirm-card .cc-edit:hover { color: var(--heat); border-color: var(--heat-20); }\n .confirm-card .cc-body { font-size: 13px; color: var(--accent-black); }\n .confirm-card .cc-body .ln { display: flex; align-items: center; gap: 8px; padding: 2px 0; font-size: 12.5px; color: var(--black-alpha-56); flex-wrap: wrap; }\n .confirm-card .cc-body .ln b { color: var(--accent-black); font-weight: 500; }\n\n .section-sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .08em; text-transform: uppercase; margin: 18px 0 10px; }\n\n .bill-list { border: 1px solid var(--border-faint); border-radius: var(--r-md); background: var(--surface); overflow: hidden; }\n .bill-row { display: grid; grid-template-columns: 1fr auto 80px; align-items: baseline; gap: 12px; padding: 11px 16px; border-bottom: 1px solid var(--border-faint); }\n .bill-row:last-child { border-bottom: 0; }\n .bill-row .l { font-size: 12.5px; color: var(--accent-black); }\n .bill-row .l .l-sub { color: var(--black-alpha-48); font-size: 11.5px; margin-left: 6px; }\n .bill-row .qty { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; text-align: right; }\n .bill-row .amt { font-family: var(--font-mono); font-size: 12.5px; color: var(--accent-black); font-variant-numeric: tabular-nums; text-align: right; }\n .bill-row.subtotal { background: var(--background-lighter); }\n .bill-row.subtotal .l { color: var(--black-alpha-56); font-size: 12px; }\n .bill-row.total { background: var(--background-lighter); border-top: 1px solid var(--black-alpha-12); }\n .bill-row.total .l { font-weight: 600; font-size: 13px; }\n .bill-row.total .amt { font-size: 16px; font-weight: 600; color: var(--accent-black); }\n .bill-row.total .amt small { font-size: 11px; color: var(--black-alpha-48); font-weight: 500; margin-left: 2px; }\n\n .balance-row { display: flex; align-items: center; gap: 14px; padding: 14px 16px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); margin-top: 10px; }\n .balance-row .bl { display: flex; align-items: center; gap: 8px; flex: 1; flex-wrap: wrap; }\n .balance-row .bl svg { width: 14px; height: 14px; color: var(--black-alpha-56); }\n .balance-row .bl .lbl { font-size: 12.5px; color: var(--black-alpha-56); }\n .balance-row .bl .val { font-family: var(--font-mono); font-size: 14px; color: var(--accent-black); font-variant-numeric: tabular-nums; font-weight: 500; }\n .balance-row .bl .arrow { color: var(--black-alpha-48); font-family: var(--font-mono); }\n .balance-row.low .bl .val.after { color: var(--accent-crimson); }\n .balance-row .pill { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; font-size: 11.5px; font-weight: 500; border: 1px solid; white-space: nowrap; margin-left: auto; }\n .balance-row .pill .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }\n .balance-row .pill.ok { background: var(--forest-bg); border-color: var(--forest-bd); color: var(--accent-forest); }\n .balance-row .pill.err { background: var(--crimson-bg); border-color: var(--crimson-bd); color: var(--accent-crimson); }\n .balance-row .pill.err a { margin-left: 4px; text-decoration: underline; cursor: pointer; }\n\n .eta-block { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px; }\n .eta-tile { padding: 14px 16px; border: 1px solid var(--border-faint); border-radius: var(--r-md); background: var(--surface); }\n .eta-tile .lbl { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-48); letter-spacing: .08em; text-transform: uppercase; margin-bottom: 6px; }\n .eta-tile .v { font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: -.01em; }\n .eta-tile .v small { font-size: 11px; color: var(--black-alpha-48); font-weight: 500; margin-left: 2px; }\n .eta-tile .desc { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; margin-top: 6px; }\n\n /* ── SVG checkbox · per design spec (no CSS hack) ── */\n .check-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; font-size: 12.5px; color: var(--black-alpha-56); cursor: pointer; user-select: none; }\n .check-row:hover .check-box { border-color: var(--black-alpha-56); }\n .check-box { width: 16px; height: 16px; background: var(--surface); border: 1px solid var(--black-alpha-24); flex-shrink: 0; border-radius: var(--r-sm); display: grid; place-items: center; transition: background var(--t-base), border-color var(--t-base); }\n .check-row.on .check-box { background: var(--heat); border-color: var(--heat); background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E\"); background-repeat: no-repeat; background-position: center; background-size: 11px 11px; }\n .check-row.on .lab { color: var(--accent-black); }\n .check-row .lab b { color: var(--accent-black); font-weight: 500; }\n .check-row .lab .mono { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); margin-left: 6px; letter-spacing: .02em; }\n\n .tos-row { display: flex; align-items: center; gap: 10px; padding: 14px 16px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); margin-top: 14px; cursor: pointer; font-size: 12.5px; color: var(--black-alpha-56); user-select: none; }\n .tos-row:hover .check-box { border-color: var(--black-alpha-56); }\n .tos-row.on { background: var(--heat-12); border-color: var(--heat-20); }\n .tos-row.on .check-box { background: var(--heat); border-color: var(--heat); background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E\"); background-repeat: no-repeat; background-position: center; background-size: 11px 11px; }\n .tos-row.on .lab { color: var(--accent-black); }\n .tos-row .lab a { color: var(--heat); text-decoration: underline; cursor: pointer; }\n\n /* preview panel */\n .wiz-preview { position: sticky; top: 24px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 18px; }\n .wiz-preview input,\n .wiz-preview textarea,\n .wiz-preview select { box-sizing: border-box; max-width: 100%; }\n .wiz-preview::before, .wiz-preview::after { content: ''; position: absolute; width: 14px; height: 14px; background: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 21' fill='%23e8e8e8'%3E%3Cpath d='M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z'/%3E%3C/svg%3E\") no-repeat center; background-size: contain; pointer-events: none; }\n .wiz-preview::before { top: -7px; left: -7px; }\n .wiz-preview::after { bottom: -7px; right: -7px; }\n .pv-h { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .08em; margin-bottom: 12px; text-transform: uppercase; display: flex; justify-content: space-between; }\n .pv-h .live { display: inline-flex; align-items: center; gap: 5px; color: var(--heat); }\n .pv-h .live::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--heat); animation: pulse 1.6s ease-in-out infinite; }\n @keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: .35 } }\n .pv-title { font-size: 14px; font-weight: 600; line-height: 1.3; margin-bottom: 14px; word-break: break-all; }\n .pv-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--border-faint); border: 1px solid var(--border-faint); margin-bottom: 14px; }\n .pv-metric { padding: 10px 12px; background: var(--surface); }\n .pv-metric .l { font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }\n .pv-metric .v { font-size: 18px; font-weight: 600; margin-top: 3px; font-variant-numeric: tabular-nums; color: var(--accent-black); }\n .pv-metric .v small { font-size: 11px; color: var(--black-alpha-48); font-weight: 500; }\n .pv-metric.accent .v { color: var(--heat); }\n .pv-section { margin-top: 14px; }\n .pv-section .lbl { font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; margin-bottom: 8px; }\n .pv-flow { display: flex; flex-wrap: wrap; gap: 4px 0; font-size: 11.5px; color: var(--black-alpha-56); align-items: center; line-height: 1.7; }\n .pv-flow .node { padding: 2px 7px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--accent-black); font-weight: 500; }\n .pv-flow .arrow { color: var(--heat); margin: 0 5px; display: inline-flex; align-items: center; }\n .pv-flow .arrow svg { display: block; }\n .pv-list { list-style: none; padding: 0; margin: 0; }\n .pv-list li { font-size: 11.5px; color: var(--black-alpha-56); padding: 4px 0; display: flex; align-items: center; gap: 6px; }\n .pv-list li::before { content: ''; width: 11px; height: 11px; flex-shrink: 0; background: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23FA5D19' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12l5 5L20 6'/%3E%3C/svg%3E\") no-repeat center; background-size: contain; }\n .pv-foot { margin-top: 14px; padding-top: 12px; border-top: 1px dashed var(--border-faint); font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); display: flex; justify-content: space-between; }\n .pv-foot strong { color: var(--accent-black); font-weight: 500; }\n /* 精简版计费 + 余额 */\n .pv-bill-summary { margin-top: 14px; padding: 10px 12px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm); display: flex; flex-direction: column; gap: 6px; }\n .pv-bill-summary .row { display: flex; align-items: baseline; justify-content: space-between; font-size: 12px; }\n .pv-bill-summary .row .k { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .pv-bill-summary .row .v { font-variant-numeric: tabular-nums; color: var(--accent-black); }\n .pv-bill-summary .row .v b { color: var(--heat); font-size: 14px; font-weight: 700; }\n .pv-bill-summary .row .v.low { color: var(--accent-crimson); }\n .pv-agree-row { margin-top: 10px; }\n .pv-agree-row label { display: flex; align-items: flex-start; gap: 6px; cursor: pointer; font-size: 11.5px; color: var(--black-alpha-56); line-height: 1.5; }\n .pv-agree-row input[type=\"checkbox\"] { margin-top: 2px; cursor: pointer; flex-shrink: 0; }\n .pv-agree-row a { color: var(--heat); }\n</style>\n</head>\n<body>\n<div id=\"page\">\n\n<div class=\"page-head\">\n <div>\n <h1>新建项目</h1>\n <div class=\"sub\"><span class=\"mono\">// 商品 → 配置 · 2 步开始生成</span></div>\n </div>\n <div class=\"actions\">\n <a class=\"btn btn-ghost\" href=\"projects.html\">退出</a>\n </div>\n</div>\n\n<div class=\"wizard\">\n <nav class=\"steps\" id=\"rail\"></nav>\n <div id=\"wiz-body\"></div>\n <aside class=\"wiz-preview\" id=\"preview\"></aside>\n</div>\n\n</div>\n\n<!-- ===== 商品库 全屏弹窗 (单选,新建项目用) ===== -->\n<div class=\"pl-modal-bg\" id=\"pl-modal-bg\">\n <div class=\"pl-modal\">\n <div class=\"pl-modal-h\">\n <h2>商品库</h2>\n <span class=\"ct\" id=\"pl-total-ct\"></span>\n <div class=\"actions\">\n <button class=\"x\" type=\"button\" id=\"pl-close-btn\" aria-label=\"关闭\">\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6 6l12 12M6 18L18 6\"/></svg>\n </button>\n </div>\n </div>\n <div class=\"pl-modal-body\">\n <aside class=\"pl-side\" id=\"pl-side\">\n <!-- JS 渲染分类 (动态从 PRODUCTS) -->\n </aside>\n <div class=\"pl-main\">\n <div class=\"pl-toolbar\">\n <div class=\"search\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/></svg>\n <input type=\"text\" id=\"pl-search-input\" placeholder=\"搜索商品名\">\n </div>\n <button class=\"btn-new\" type=\"button\" id=\"pl-new-btn\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 5v14M5 12h14\"/></svg>\n 新建商品\n </button>\n </div>\n <div class=\"pl-scroll\">\n <div class=\"pl-grid\" id=\"pl-grid\"></div>\n </div>\n </div>\n </div>\n <div class=\"pl-modal-f\">\n <div class=\"summary\">// 单选:点击商品即选用,自动关闭</div>\n <button class=\"btn\" type=\"button\" id=\"pl-cancel-btn\">取消</button>\n </div>\n </div>\n</div>\n\n<script src=\"/exact/assets/icons.js?v=2026052608\"></script>\n<script src=\"/exact/assets/shell.js?v=2026060101\"></script>\n<script src=\"/exact/assets/new-product-drawer.js?v=202605211643\"></script>\n<script>Shell.render({ active: 'projects', crumbs: [{ label: '工作台', href: 'index.html' }, { label: '视频项目', href: 'projects.html' }, { label: '新建项目' }] });</script>\n\n<script>\n/* ============================================================\n 新建项目 · 4 步动态向导 (vanilla JS state machine)\n ============================================================ */\n(function () {\n 'use strict';\n\n /* ---------- data ---------- */\n\n const PRODUCTS = [\n { id: 'mask', name: '透真玻尿酸补水面膜', cat: '美妆个护', price: 39.9, imgs: 3, points: ['透明质酸 + B5', '30g 大容量精华', '0 香精 0 酒精'], tags: ['熬夜党', '敏感肌'], date: '2026-05-15' },\n { id: 'earphone', name: '南卡 Lite Pro 蓝牙耳机', cat: '数码 3C', price: 199, imgs: 5, points: ['主动降噪', '32 小时续航', 'IP55 防水'], tags: ['通勤', '运动'], date: '2026-05-12' },\n { id: 'noodle', name: '滋啦速食牛肉面 · 6 桶装', cat: '食品饮料', price: 49.9, imgs: 4, points: ['3 分钟出餐', '真材实料牛肉', '0 防腐剂'], tags: ['加班', '独居'], date: '2026-05-10' },\n { id: 'sun', name: '透真清透物理防晒霜', cat: '美妆个护', price: 69, imgs: 4, points: ['SPF50 PA+++', '纯物理防晒', '不泛白不假面'], tags: ['SPF50', '通勤'], date: '2026-05-08' },\n { id: 'coffee', name: '三顿半同款冻干咖啡粉', cat: '食品饮料', price: 89, imgs: 6, points: ['冷热水秒溶', '意式深烘', '24 颗轻便装'], tags: ['提神', '早八'], date: '2026-05-05' },\n { id: 'fryer', name: '小熊 4L 可视空气炸锅', cat: '家居家电', price: 159, imgs: 5, points: ['可视化窗口', '4L 大容量', '低脂少油'], tags: ['小户型', '健康'], date: '2026-05-03' },\n { id: 'yoga', name: '露露同款裸感瑜伽裤', cat: '运动户外', price: 119, imgs: 8, points: ['裸感面料', '高弹回弹', '随心动随心穿'], tags: ['健身房', '通勤'], date: '2026-04-30' },\n ];\n\n const RECENT_IDS = ['mask', 'sun', 'coffee', 'earphone'];\n const CATS = ['全部', '美妆个护', '数码 3C', '食品饮料', '家居家电', '运动户外'];\n\n const SOURCES = [\n { id: 'ai', name: 'AI 全生', tag: '最常用', desc: 'LLM 全权决定脚本走向,最省事。后续仍可在故事板阶段微调。',\n icon: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z\"/><path d=\"M19 16l.85 2.3L22 19l-2.15.7L19 22l-.85-2.3L16 19l2.15-.7L19 16z\"/></svg>' },\n { id: 'theme', name: '一句话主题', tag: '轻引导', desc: '你给一句切入主题,AI 按此扩写。推荐 5–30 字。',\n icon: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 18h6\"/><path d=\"M10 22h4\"/><path d=\"M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14\"/></svg>' },\n { id: 'manual', name: '自带脚本', tag: '我已有稿', desc: '粘贴或上传完整脚本,系统按镜头自动切分并适配商品。',\n icon: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\"/><path d=\"M14 2v6h6M9 13h6M9 17h6\"/></svg>' },\n ];\n\n const DURATIONS = [\n { id: '0-10', label: '0-10 秒', shots: [3, 4], tag: '黄金完播', completion: 52, conversion: 1.6 },\n { id: '0-15', label: '0-15 秒', shots: [4, 5], tag: '完播率最佳', completion: 42, conversion: 1.8 },\n { id: '0-30', label: '0-30 秒', shots: [6, 8], tag: '卖点详解', completion: 32, conversion: 2.1 },\n { id: '0-60', label: '0-60 秒', shots: [10, 12], tag: '故事化', completion: 26, conversion: 2.4 },\n ];\n\n const STYLES = [\n { id: 'pain', name: '痛点种草', note: '用户痛点切入,以「我懂你」的口吻引出产品。', tag: '最常用', flow: ['痛点', '共鸣', '产品', '效果', '引导'] },\n { id: 'review', name: '开箱测评', note: '朋友式分享,从开箱到使用感受娓娓道来。', flow: ['开箱', '首印象', '试用', '对比', '结论'] },\n { id: 'compare', name: '对比展示', note: '「用前 vs 用后 / 同类 vs 本品」直观呈现。', flow: ['对照', '差距', '本品', '数据', '购买'] },\n ];\n\n const PERSONAS = [\n { id: 'urban', name: '都市白领女性', sub: '25-30 岁', metric: '大盘消费力', defaults: { duration: '0-15', style: 'pain' } },\n { id: 'bestie', name: '闺蜜种草', sub: '邻家女孩', metric: '复购最高', defaults: { duration: '0-15', style: 'pain' } },\n { id: 'ceo', name: '总裁亲选', sub: '创始人 IP', metric: '30 万销额案例', defaults: { duration: '0-30', style: 'pain' } },\n { id: 'reviewer', name: '专业测评师', sub: '垂类达人', metric: '互动 +30%', defaults: { duration: '0-30', style: 'review' } },\n { id: 'mom', name: '实用宝妈', sub: '家庭决策者', metric: '母婴/家清稳', defaults: { duration: '0-30', style: 'pain' } },\n { id: 'genz', name: '学生党', sub: 'Z 世代 18-24', metric: '平价快消', defaults: { duration: '0-10', style: 'compare' } },\n ];\n\n /* ---------- 合并其它页面创建的商品 ---------- */\n // 商品库 / 工作台 / 新建项目 都共用同一 drawer(assets/new-product-drawer.js),\n // 该 drawer 在 save() 时把新商品写入 sessionStorage['fs-extra-products']。\n // 这里在 PRODUCTS hardcoded 数据后,把 storage 中尚不在 PRODUCTS 的商品 unshift\n // 到列表头部 → 用户跨页面新建的商品在 step 1 也能立即看到。\n (function mergeExtraProducts() {\n try {\n const raw = sessionStorage.getItem('fs-extra-products');\n if (!raw) return;\n const list = JSON.parse(raw);\n if (!Array.isArray(list) || !list.length) return;\n const existingIds = new Set(PRODUCTS.map(p => p.id));\n // 按 createdAt 倒序(最新在前),逐个 unshift\n list.slice().sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)).forEach(x => {\n if (existingIds.has(x.id)) return;\n existingIds.add(x.id);\n PRODUCTS.unshift({\n id: x.id,\n name: x.name || '未命名商品',\n cat: x.cat || '未分类',\n price: null, // 表单未采集价格\n imgs: Math.max(1, x.assets || 0), // 用素材数兜底,至少 1\n points: Array.isArray(x.bullets) ? x.bullets : [],\n tags: x.target ? String(x.target).split(/[ ,、、]+/).filter(Boolean).slice(0, 3) : [],\n date: x.date || (x.createdAt ? new Date(x.createdAt).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10)),\n createdAt: x.createdAt || Date.now(),\n });\n });\n } catch (e) { /* storage 不可用就静默放弃 */ }\n })();\n\n const USER_EMAIL = 'li@shop.com';\n const ACCOUNT_BALANCE = 327.40;\n\n /* ---------- state ---------- */\n\n const state = {\n currentStep: 1,\n productId: null,\n pickSearch: '',\n pickCat: '全部',\n pickSort: 'recent', // recent | name | added\n pickView: 'grid', // grid | list\n pickPage: 1,\n pickPageSize: 7, // 固定 4 列 × 2 行 = 8 tile,首页含 createCard 占 1 位 → 7 商品\n sourceId: null,\n themeText: '',\n manualScript: '',\n projectName: '',\n duration: null,\n scriptStyle: null,\n persona: null,\n points: {},\n recoDismissed: false,\n notifyEmail: true,\n notifyWeChat: false,\n agreed: false,\n };\n\n /* ---------- helpers ---------- */\n\n function $(sel) { return document.querySelector(sel); }\n function esc(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, c => ({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));\n }\n function getProduct() { return PRODUCTS.find(p => p.id === state.productId) || null; }\n function getSource() { return SOURCES.find(s => s.id === state.sourceId) || null; }\n function getPersona() { return PERSONAS.find(p => p.id === state.persona) || null; }\n function getDuration() { return DURATIONS.find(d => d.id === state.duration) || null; }\n function getStyle() { return STYLES.find(s => s.id === state.scriptStyle) || null; }\n function getShots() { const d = getDuration(); return d ? (d.shots[0] + d.shots[1]) / 2 : 0; }\n function getCost() {\n // 全部返回 number,使用方各自 toFixed(2),避免 stringify 后再调 toFixed 报错\n const p = getProduct();\n const script = 0.20;\n const assets = p ? p.imgs * 0.30 : 0;\n const sb = 0.40; // 故事板 storyboard\n const video = getShots() * 0.30; // 视频片段\n const exp = 0.10; // 拼接导出\n const subtotal = script + assets + sb + video + exp;\n const fee = +(subtotal * 0.05).toFixed(2);\n const total = +(subtotal + fee).toFixed(2);\n return {\n script, assets, sb, video, export: exp,\n storyboard: sb, render: video, // 别名,兼容旧调用点\n subtotal, fee, total,\n };\n }\n function balanceAfter() { return +(ACCOUNT_BALANCE - getCost().total).toFixed(2); }\n function etaMinutes() {\n const p = getProduct();\n return Math.max(3, Math.round(2 + getShots() * 0.4 + (p ? p.imgs * 0.2 : 0)));\n }\n function canPass1() { return !!state.productId; }\n function canPass2() {\n const s = getSource(); if (!s) return false;\n if (s.id === 'theme') return state.themeText.trim().length >= 4;\n if (s.id === 'manual') return state.manualScript.trim().length >= 20;\n return true;\n }\n function canPass3() { return state.projectName.trim().length >= 2; }\n function canFinish() { return canPass1() && canPass2() && canPass3() && state.agreed && balanceAfter() >= 5; }\n\n /* ---------- icons ---------- */\n\n const icon = (name, opts) => window.IconKit ? window.IconKit.svg(name, opts) : '';\n const ICONS = {\n check: icon('check'),\n search: icon('search'),\n plus: icon('plus'),\n x: icon('x'),\n bulb: icon('info'),\n arrow: icon('arrowRight'),\n wallet: icon('wallet'),\n };\n\n /* ---------- actions ---------- */\n\n function selectProduct(id) {\n state.productId = id;\n const p = getProduct();\n if (!state.projectName) {\n state.projectName = p.name.split(' ')[0] + ' · 痛点种草 · v1';\n }\n state.points = {};\n p.points.forEach(pt => { state.points[pt] = false; });\n render();\n }\n\n function selectSource(id) {\n state.sourceId = id;\n render();\n }\n\n function goNext() {\n if (state.currentStep === 1 && !canPass1()) return;\n if (state.currentStep === 2 && !canPass2()) return;\n if (state.currentStep === 3 && !canPass3()) return;\n if (state.currentStep < 4) state.currentStep++;\n render();\n window.scrollTo({ top: 0, behavior: 'smooth' });\n }\n function goPrev() {\n if (state.currentStep > 1) state.currentStep--;\n render();\n window.scrollTo({ top: 0, behavior: 'smooth' });\n }\n function jumpTo(n) {\n if (n < state.currentStep) {\n state.currentStep = n;\n render();\n window.scrollTo({ top: 0, behavior: 'smooth' });\n }\n }\n\n function applyPreset() {\n const p = getPersona();\n state.duration = p.defaults.duration;\n state.scriptStyle = p.defaults.style;\n state.recoDismissed = false;\n render();\n }\n\n function startGenerate() {\n const p = getProduct(), d = getDuration(), st = getStyle();\n if (!p || !d || !st || state.projectName.trim().length < 2) return;\n // 持久化项目, 让 projects.html 下次加载时自动 prepend 到列表\n try {\n const seconds = (d.id.split('-')[1] || '15');\n const baseName = state.projectName.trim();\n const project = {\n id: 'proj-' + Date.now(),\n name: baseName + (/\\sv\\d+$/.test(baseName) ? '' : ' · v1'),\n product: p.name,\n source: '',\n shots: Math.round(getShots()),\n durationLabel: '0-' + seconds + 's',\n status: 'wip',\n stage: 1,\n pillText: '脚本生成中',\n createdAt: Date.now(),\n };\n const KEY = 'fs-extra-projects';\n const list = JSON.parse(localStorage.getItem(KEY) || '[]');\n list.push(project);\n localStorage.setItem(KEY, JSON.stringify(list));\n } catch (e) { /* localStorage 不可用时降级到只跳转 */ }\n if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('项目已创建', '进入 pipeline · Stage 1 脚本');\n setTimeout(() => { location.href = 'pipeline.html?stage=1&product=' + encodeURIComponent(p.name); }, 300);\n }\n\n function openNewProduct() {\n if (!window.NewProductDrawer) {\n if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('Drawer 未加载', '检查 new-product-drawer.js');\n return;\n }\n window.NewProductDrawer.open({\n onSave: function (p) {\n // p = { id, name, cat, target, points: string[], images: [...], imgs: N }\n // 适配 wizard 数据结构\n const now = Date.now();\n const product = {\n id: p.id,\n name: p.name,\n cat: p.cat,\n price: null, // 表单暂未收集价格,显示时跳过\n imgs: p.imgs,\n points: p.points,\n tags: p.target ? p.target.split(/[ ,、、]+/).filter(Boolean).slice(0, 3) : [],\n date: new Date(now).toISOString().slice(0, 10),\n createdAt: now,\n };\n // 置顶插入,让用户立刻看到\n PRODUCTS.unshift(product);\n // 自动选中(同时种子项目名 / 卖点),触发 render() 刷新 step 1\n selectProduct(product.id);\n // 商品库若开着 → 强制 reset filter/query/搜索框,确保新商品一定可见\n const libBg = document.getElementById('pl-modal-bg');\n if (libBg && libBg.classList.contains('show')) {\n _plCatFilter = '';\n _plQuery = '';\n const searchInput = document.getElementById('pl-search-input');\n if (searchInput) searchInput.value = '';\n document.getElementById('pl-total-ct').textContent = '// 共 ' + PRODUCTS.length + ' 个商品';\n renderProdLibSide();\n renderProdLib();\n }\n }\n });\n }\n\n // ─── 商品库全屏弹窗 (单选) ───\n let _plQuery = '';\n let _plCatFilter = '';\n function renderProdLib() {\n const grid = document.getElementById('pl-grid');\n let list = PRODUCTS;\n if (_plCatFilter) list = list.filter(x => x.cat === _plCatFilter);\n if (_plQuery) list = list.filter(x => x.name.includes(_plQuery));\n grid.innerHTML = list.map(p => `\n <div class=\"pl-card${state.productId === p.id ? ' selected' : ''}\" data-id=\"${p.id}\">\n <div class=\"pl-check\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></div>\n <div class=\"placeholder pl-thumb\"><span class=\"ph-frame\">${esc(p.name)}</span></div>\n <div class=\"pl-name\">${esc(p.name)}</div>\n <div class=\"pl-meta\">${esc(p.cat)}${p.price != null ? ' · ¥' + p.price : ''} · ${p.imgs} 张图</div>\n </div>\n `).join('');\n grid.querySelectorAll('.pl-card').forEach(card => {\n card.addEventListener('click', () => {\n selectProduct(card.dataset.id);\n closeProdLib();\n });\n });\n }\n function renderProdLibSide() {\n // 动态分类(从 PRODUCTS 统计)\n const counts = { '全部': PRODUCTS.length };\n PRODUCTS.forEach(p => { counts[p.cat] = (counts[p.cat] || 0) + 1; });\n const cats = ['全部', ...Object.keys(counts).filter(k => k !== '全部')];\n const side = document.getElementById('pl-side');\n side.innerHTML = '<div class=\"pl-side-h\">分类</div>' +\n cats.map(c => `<div class=\"pl-side-item${(_plCatFilter === '' && c === '全部') || _plCatFilter === c ? ' active' : ''}\" data-cat=\"${c === '全部' ? '' : esc(c)}\">${esc(c)} <span class=\"ct\">${counts[c]}</span></div>`).join('');\n side.querySelectorAll('.pl-side-item').forEach(item => {\n item.addEventListener('click', () => {\n side.querySelectorAll('.pl-side-item').forEach(x => x.classList.remove('active'));\n item.classList.add('active');\n _plCatFilter = item.dataset.cat;\n renderProdLib();\n });\n });\n }\n function openProdLib() {\n _plQuery = '';\n _plCatFilter = '';\n document.getElementById('pl-search-input').value = '';\n document.getElementById('pl-total-ct').textContent = '// 共 ' + PRODUCTS.length + ' 个商品';\n renderProdLibSide();\n renderProdLib();\n document.getElementById('pl-modal-bg').classList.add('show');\n }\n function closeProdLib() {\n document.getElementById('pl-modal-bg').classList.remove('show');\n }\n\n // expose for inline onclick\n window._wiz = {\n selectProduct, selectSource, goNext, goPrev, jumpTo, applyPreset, startGenerate, openNewProduct,\n openProdLib, closeProdLib,\n setSearch: v => { state.pickSearch = v; state.pickPage = 1; renderStep1Only(); },\n setCat: v => { state.pickCat = v; state.pickPage = 1; renderStep1Only(); },\n setSort: v => { state.pickSort = v; state.pickPage = 1; renderStep1Only(); },\n setView: v => { state.pickView = v; renderStep1Only(); },\n setPage: v => { state.pickPage = Math.max(1, v); renderStep1Only(); },\n setPageSize: v => { state.pickPageSize = v; state.pickPage = 1; renderStep1Only(); },\n clearPickFilters: () => { state.pickSearch=''; state.pickCat='全部'; state.pickPage=1; renderStep1Only(); },\n setTheme: v => { state.themeText = v; updateFootOnly(); updatePreviewLive(); },\n setScript: v => { state.manualScript = v; updateFootOnly(); },\n setName: v => { state.projectName = v; updatePreviewLive(); updateFootOnly(); updateRailOnly(); },\n setDur: v => { state.duration = v; render(); },\n setStyle: v => { state.scriptStyle = v; render(); },\n setPersona:v => { state.persona = v; state.recoDismissed = false; render(); },\n togglePt: k => { state.points[k] = !state.points[k]; render(); },\n dismissReco: () => { state.recoDismissed = true; render(); },\n toggleEmail: () => { state.notifyEmail = !state.notifyEmail; render(); },\n toggleWeChat: () => { state.notifyWeChat = !state.notifyWeChat; render(); },\n toggleTos: () => { state.agreed = !state.agreed; render(); },\n toggleAgree: v => { state.agreed = v; render(); },\n setProjectName: v => {\n state.projectName = v;\n // 仅更新预览的标题, 不重新 render (避免 input 失焦)\n const titleEl = $('#preview .pv-title');\n if (titleEl) {\n // 仅替换标题文字部分,保留 input\n const p = getProduct();\n const text = state.projectName || (p ? p.name + ' · 待命名' : '未命名项目');\n const node = titleEl.childNodes[0];\n if (node && node.nodeType === 3) node.nodeValue = text;\n }\n },\n };\n\n /* ============================================================\n RENDER\n ============================================================ */\n\n function railConfig() {\n const p = getProduct(), du = getDuration(), st = getStyle();\n const cfgReady = !!(du && st);\n return [\n { n: 1, label: '选择商品', desc: p ? p.name : '未选择', done: canPass1() },\n { n: 2, label: '项目配置', desc: cfgReady ? (du.label + ' · ' + st.name) : '时长 · 风格 · 人物', done: cfgReady && canPass3() },\n ];\n }\n\n // 平滑滚动到 step section\n function scrollToStep(n) {\n const target = document.querySelector('#step-pane-' + n);\n if (!target) return;\n target.scrollIntoView({ behavior: 'smooth', block: 'start' });\n }\n // 暴露 (rail 内 onclick 引用)\n window._wiz_scrollToStep = scrollToStep;\n\n function renderRail() {\n const cfg = railConfig();\n const html = cfg.map(s => {\n const stt = s.done ? 'done' : '';\n const numContent = s.done ? ICONS.check : s.n;\n return `<div class=\"step ${stt} clickable\" onclick=\"_wiz_scrollToStep(${s.n})\">\n <div class=\"num\">${numContent}</div>\n <div>\n <div class=\"label\">${esc(s.label)}</div>\n <div class=\"desc\">${esc(s.desc)}</div>\n </div>\n </div>`;\n }).join('');\n $('#rail').innerHTML = html;\n }\n\n function productCardHTML(p) {\n const selected = state.productId === p.id;\n return `<div class=\"product-card${selected ? ' selected' : ''}\" onclick=\"_wiz.selectProduct('${p.id}')\">\n <div class=\"placeholder product-thumb\"><span class=\"ph-frame\">${esc(p.name)} · 1200×800</span></div>\n <div class=\"product-body\">\n <div class=\"product-name\">${esc(p.name)}</div>\n <div class=\"product-cat\">${esc(p.cat)}</div>\n <div class=\"product-date\">${esc(p.date || '')} 创建</div>\n </div>\n </div>`;\n }\n\n function pickerFilteredProducts() {\n const q = (state.pickSearch || '').trim().toLowerCase();\n let list = PRODUCTS.filter(p => {\n if (state.pickCat !== '全部' && p.cat !== state.pickCat) return false;\n if (q) {\n const blob = (p.name + ' ' + p.cat + ' ' + (p.tags || []).join(' ')).toLowerCase();\n if (!blob.includes(q)) return false;\n }\n return true;\n });\n if (state.pickSort === 'name') {\n list = list.slice().sort((a, b) => a.name.localeCompare(b.name, 'zh'));\n } else if (state.pickSort === 'added') {\n list = list.slice();\n } else {\n // recent 排序优先级:\n // 1) 用户新建的商品(有 createdAt) — 按 createdAt 倒序,最新在最前(紧挨 createCard 按钮)\n // 2) RECENT_IDS 命中的商品 — 按命中顺序\n // 3) 其余 — 保持原始顺序\n const recent = new Map(RECENT_IDS.map((id, i) => [id, i]));\n function weight(p) {\n if (p.createdAt) return -p.createdAt; // 负值 → 最大的负数(最近创建)排最前\n if (recent.has(p.id)) return recent.get(p.id);\n return Number.POSITIVE_INFINITY;\n }\n list = list.slice().sort((a, b) => weight(a) - weight(b));\n }\n return list;\n }\n\n function pickerPagerHTML(total) {\n const pageSize = state.pickPageSize;\n const totalPages = Math.max(1, Math.ceil(total / pageSize));\n const cur = Math.min(state.pickPage, totalPages);\n function pageBtn(n) {\n const act = n === cur ? ' active' : '';\n return `<button type=\"button\" class=\"${act.trim()}\" onclick=\"_wiz.setPage(${n})\">${n}</button>`;\n }\n const items = [];\n items.push(`<button type=\"button\"${cur === 1 ? ' disabled' : ''} onclick=\"_wiz.setPage(${cur - 1})\">‹</button>`);\n if (totalPages <= 7) {\n for (let i = 1; i <= totalPages; i++) items.push(pageBtn(i));\n } else {\n items.push(pageBtn(1));\n if (cur > 3) items.push('<span class=\"ellipsis\">…</span>');\n const lo = Math.max(2, cur - 1), hi = Math.min(totalPages - 1, cur + 1);\n for (let i = lo; i <= hi; i++) items.push(pageBtn(i));\n if (cur < totalPages - 2) items.push('<span class=\"ellipsis\">…</span>');\n items.push(pageBtn(totalPages));\n }\n items.push(`<button type=\"button\"${cur === totalPages ? ' disabled' : ''} onclick=\"_wiz.setPage(${cur + 1})\">›</button>`);\n return `<div class=\"pp-pager\">\n <span class=\"total\">共 ${total} 条</span>\n <div class=\"pages\">${items.join('')}</div>\n <span class=\"page-size\">每页 ${pageSize} 条</span>\n </div>`;\n }\n\n function renderStep1() {\n const list = pickerFilteredProducts();\n const total = list.length;\n const pageSize = state.pickPageSize;\n const totalPages = Math.max(1, Math.ceil(total / pageSize));\n const cur = Math.min(state.pickPage, totalPages);\n const pageList = list.slice((cur - 1) * pageSize, cur * pageSize);\n\n const cats = ['全部', ...new Set(PRODUCTS.map(p => p.cat))];\n const catChipActive = state.pickCat !== '全部';\n const sortLabels = { recent: '最近使用', name: '商品名称', added: '添加顺序' };\n const hasFilter = !!state.pickSearch || state.pickCat !== '全部';\n\n const catMenu = cats.map(c => `<div class=\"mi${state.pickCat === c ? ' selected' : ''}\" onclick=\"_wiz.setCat('${esc(c)}')\">\n <svg class=\"mi-check\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.6\"><polyline points=\"3 8 7 12 13 4\"/></svg>\n <span>${esc(c)}</span>\n </div>`).join('');\n\n const sortMenu = Object.keys(sortLabels).map(k => `<div class=\"mi${state.pickSort === k ? ' selected' : ''}\" onclick=\"_wiz.setSort('${k}')\">\n <svg class=\"mi-check\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.6\"><polyline points=\"3 8 7 12 13 4\"/></svg>\n <span>${sortLabels[k]}</span>\n </div>`).join('');\n\n const cards = pageList.map(productCardHTML).join('');\n const createCard = `<div class=\"pp-create-card\" onclick=\"_wiz.openNewProduct()\">\n <div class=\"pc-plus\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 5v14M5 12h14\"/></svg></div>\n <div class=\"pc-t\">创建新商品</div>\n <div class=\"pc-d\">// 在此添加一个新商品</div>\n </div>`;\n const gridContent = total === 0\n ? (createCard + `<div class=\"pp-empty\">// NO MATCH<br>没有符合筛选条件的商品 <span class=\"reset\" onclick=\"_wiz.clearPickFilters()\">[ 清空筛选 ]</span></div>`)\n : (createCard + cards);\n\n return `<div class=\"wiz-pane active\" data-step=\"1\">\n <div class=\"wiz-step-h\">\n <h2>第 1 步 · 选择商品</h2>\n <p>从商品库选一个 SKU。它的主图与卖点会被 LLM 作为脚本/资产生成的素材。</p>\n </div>\n\n <div class=\"pp-toolbar\">\n <div class=\"search-inline\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/></svg>\n <input type=\"text\" placeholder=\"搜索商品名称、标签\" value=\"${esc(state.pickSearch)}\" oninput=\"_wiz.setSearch(this.value)\">\n </div>\n <div class=\"pp-chip-wrap\" data-key=\"cat\">\n <button class=\"pp-chip${catChipActive ? ' active' : ''}\" type=\"button\" onclick=\"event.stopPropagation(); this.parentElement.classList.toggle('open'); document.querySelectorAll('.pp-chip-wrap.open').forEach(w=>{if(w!==this.parentElement)w.classList.remove('open')})\">\n <span>${state.pickCat === '全部' ? '全部分类' : esc(state.pickCat)}</span>\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg>\n </button>\n <div class=\"pp-menu\">${catMenu}</div>\n </div>\n ${hasFilter ? `<button class=\"pp-clear\" type=\"button\" onclick=\"_wiz.clearPickFilters()\">\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 4l8 8M12 4l-8 8\"/></svg>\n 清空筛选\n </button>` : ''}\n </div>\n\n <div class=\"pp-result-meta\">// 显示 ${pageList.length} / ${total} 个商品${hasFilter ? ' (已筛选)' : ''}</div>\n\n <div class=\"pp-grid${state.pickView === 'list' ? ' list-view' : ''}\">${gridContent}</div>\n\n ${total > pageSize ? pickerPagerHTML(total) : ''}\n\n <div class=\"pp-bottom-tip\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 8v5M12 16h.01\"/></svg>\n <span>找不到想要的商品?可<a onclick=\"_wiz.openNewProduct()\">创建新商品</a>,或前往 <a href=\"products.html\">商品库 · 管理商品</a></span>\n </div>\n </div>`;\n }\n\n function renderStep2() {\n const s = getSource();\n let detail = '';\n if (s) {\n if (s.id === 'ai') {\n detail = `<div class=\"source-detail\">\n <div class=\"sd-h\">// 已选 · <b>${esc(s.name)}</b></div>\n <div class=\"field-hint\" style=\"font-size: 12.5px; color: var(--black-alpha-72);\">AI 全生模式无需额外输入。下一步选定时长 / 风格 / 人设后,LLM 会自动决定切入点和卖点权重。</div>\n </div>`;\n } else if (s.id === 'theme') {\n detail = `<div class=\"source-detail\">\n <div class=\"sd-h\">// 已选 · <b>${esc(s.name)}</b></div>\n <div class=\"field\" style=\"margin-bottom: 0;\">\n <label class=\"field-label\">一句话主题<span class=\"req\">*</span></label>\n <input class=\"input\" placeholder=\"例:熬夜党的急救面膜 / 加班吃啥不内疚\" value=\"${esc(state.themeText)}\" oninput=\"_wiz.setTheme(this.value)\">\n <div class=\"field-hint\">推荐 5–30 字。这句话会作为 LLM 扩写的锚点,越具体越聚焦。</div>\n </div>\n </div>`;\n } else if (s.id === 'manual') {\n detail = `<div class=\"source-detail\">\n <div class=\"sd-h\">// 已选 · <b>${esc(s.name)}</b></div>\n <div class=\"field\" style=\"margin-bottom: 0;\">\n <label class=\"field-label\">粘贴脚本内容<span class=\"req\">*</span></label>\n <textarea class=\"textarea\" placeholder=\"粘贴你的脚本内容(旁白 / 镜头描述均可,系统会自动切分镜头)\" oninput=\"_wiz.setScript(this.value)\">${esc(state.manualScript)}</textarea>\n <div class=\"field-hint\">最少 20 字。镜头数由你的脚本自然段落决定,时长 / 风格仍会影响后期渲染节奏。</div>\n </div>\n </div>`;\n }\n }\n return `<div class=\"wiz-pane active\" data-step=\"2\">\n <div class=\"wiz-step-h\">\n <h2>第 2 步 · 脚本来源</h2>\n <p>决定 LLM 如何获得初稿脚本。三种方式由「最省事」到「最保真原意」。</p>\n </div>\n <div class=\"source-row\">\n ${SOURCES.map(s => `<div class=\"source-card${state.sourceId === s.id ? ' selected' : ''}\" onclick=\"_wiz.selectSource('${s.id}')\">\n <span class=\"src-ic\">${s.icon}</span>\n <h4>${esc(s.name)}</h4>\n <span class=\"src-tag\">[ ${esc(s.tag)} ]</span>\n <p class=\"src-desc\">${esc(s.desc)}</p>\n </div>`).join('')}\n </div>\n ${detail}\n </div>`;\n }\n\n function renderStep3() {\n const personaObj = getPersona(), durObj = getDuration(), styleObj = getStyle();\n let showReco = false, recoDur = null, recoStyle = null;\n if (personaObj && state.duration && state.scriptStyle) {\n const recoMismatch = personaObj.defaults.duration !== state.duration || personaObj.defaults.style !== state.scriptStyle;\n showReco = recoMismatch && !state.recoDismissed;\n recoDur = DURATIONS.find(d => d.id === personaObj.defaults.duration);\n recoStyle = STYLES.find(s => s.id === personaObj.defaults.style);\n }\n\n return `<div class=\"wiz-pane active\" data-step=\"2\">\n <div class=\"wiz-step-h\">\n <h2>第 2 步 · 项目配置</h2>\n <p>这些设置会影响 LLM 生成脚本的方向,确认后会进入流水线第 1 步(脚本生成)。</p>\n </div>\n\n <div class=\"config-row\">\n <div class=\"field\">\n <label class=\"field-label\">项目名称<span class=\"req\">*</span></label>\n <input class=\"input\" value=\"${esc(state.projectName)}\" oninput=\"_wiz.setName(this.value)\">\n </div>\n <div class=\"field\">\n <label class=\"field-label\">视频时长<span class=\"req\">*</span></label>\n <select class=\"input duration-select\" onchange=\"_wiz.setDur(this.value)\">\n <option value=\"\" disabled ${state.duration ? '' : 'selected'}>选择时长</option>\n ${DURATIONS.map(d => `<option value=\"${esc(d.id)}\" ${state.duration === d.id ? 'selected' : ''}>${esc(d.label)} · ${d.shots[0]}-${d.shots[1]} 镜</option>`).join('')}\n </select>\n </div>\n </div>\n\n <div class=\"field\">\n <label class=\"field-label\">脚本风格</label>\n <div class=\"opt-row cols-4\">\n ${STYLES.map(s => `<div class=\"opt-card${state.scriptStyle === s.id ? ' selected' : ''}\" onclick=\"_wiz.setStyle('${s.id}')\">\n <h4>${esc(s.name)}</h4>\n <div class=\"note\">${esc(s.note)}</div>\n ${s.tag ? `<span class=\"badge\">[ ${esc(s.tag)} ]</span>` : ''}\n </div>`).join('')}\n </div>\n </div>\n\n <div class=\"field\">\n <label class=\"field-label\">人物设定</label>\n <div class=\"opt-row cols-6\">\n ${PERSONAS.map(p => `<div class=\"opt-card${state.persona === p.id ? ' selected' : ''}\" onclick=\"_wiz.setPersona('${p.id}')\">\n <h4>${esc(p.name)}</h4>\n <div class=\"sub\">${esc(p.sub)}</div>\n <div class=\"metric\"><span class=\"val\">${esc(p.metric)}</span></div>\n </div>`).join('')}\n </div>\n\n ${showReco ? `<div class=\"reco-bubble\">\n <span class=\"ic\">${ICONS.bulb}</span>\n <div class=\"txt\">\n <span>抖音同人设 TOP 视频更常用 <strong>${esc(recoDur.label)}</strong> + <strong>${esc(recoStyle.name)}</strong></span>\n <span class=\"meta\">当前 ${esc(durObj.label)} · ${esc(styleObj.name)} → 推荐换为同人设最优组合</span>\n </div>\n <button class=\"btn-apply\" onclick=\"_wiz.applyPreset()\">一键套用</button>\n <button class=\"dismiss\" onclick=\"_wiz.dismissReco()\" aria-label=\"忽略\">${ICONS.x}</button>\n </div>` : ''}\n </div>\n\n ${Object.keys(state.points).length > 0 ? `<div class=\"field\" style=\"margin-bottom: 0;\">\n <label class=\"field-label\">关键卖点(可勾选要重点突出的)</label>\n <div class=\"theme-pill-row\">\n ${Object.entries(state.points).map(([k, v]) => `<button class=\"theme-pill${v ? ' active' : ''}\" type=\"button\" aria-pressed=\"${v ? 'true' : 'false'}\" onclick=\"_wiz.togglePt('${esc(k).replace(/'/g, \"\\\\'\")}')\">${v ? ICONS.check : ''}<span>${esc(k)}</span></button>`).join('')}\n </div>\n </div>` : ''}\n </div>`;\n }\n\n function renderStep4() {\n const p = getProduct(), s = getSource(), personaObj = getPersona(), durObj = getDuration(), styleObj = getStyle();\n const c = getCost();\n const ba = balanceAfter();\n const low = ba < 5;\n const eta = etaMinutes();\n const pointsList = Object.entries(state.points).filter(([, v]) => v).map(([k]) => k).join(' / ') || '未选';\n\n return `<div class=\"wiz-pane active\" data-step=\"4\">\n <div class=\"wiz-step-h\">\n <h2>第 4 步 · 确认与计费</h2>\n <p>核对前 3 步的选择 + 计费明细。点击「开始生成」会立刻扣款并进入流水线。</p>\n </div>\n\n <div class=\"confirm-grid\">\n <div class=\"confirm-card\">\n <div class=\"cc-h\"><span>// 商品</span><button class=\"cc-edit\" onclick=\"_wiz.jumpTo(1)\">修改</button></div>\n ${p ? `<div style=\"display:flex; gap:12px; align-items:flex-start;\">\n <div class=\"placeholder\" style=\"width:44px; height:56px; flex-shrink:0;\"><span class=\"ph-frame\">9:16</span></div>\n <div class=\"cc-body\" style=\"min-width:0;\">\n <div style=\"font-weight:600; font-size:13px;\">${esc(p.name)}</div>\n <div class=\"ln\">${esc(p.cat)}${p.price != null ? ' <span style=\"color: var(--black-alpha-32);\">·</span> <b>¥' + p.price + '</b>' : ''} <span style=\"color: var(--black-alpha-32);\">·</span> ${p.imgs} 张图</div>\n </div>\n </div>` : '<div class=\"cc-body\">未选择</div>'}\n </div>\n\n <div class=\"confirm-card\">\n <div class=\"cc-h\"><span>// 脚本来源</span><button class=\"cc-edit\" onclick=\"_wiz.jumpTo(2)\">修改</button></div>\n ${s ? `<div class=\"cc-body\">\n <div style=\"font-weight:600; font-size:13px;\">${esc(s.name)}</div>\n <div class=\"ln\">${s.id === 'ai' ? 'LLM 全权 · 走向由 Step 3 决定'\n : s.id === 'theme' ? '主题:<b style=\"margin-left:4px;\">' + esc(state.themeText || '(未填)') + '</b>'\n : '<b>' + state.manualScript.length + '</b> 字 · 自动切镜'}</div>\n </div>` : '<div class=\"cc-body\">未选择</div>'}\n </div>\n\n <div class=\"confirm-card\">\n <div class=\"cc-h\"><span>// 项目配置</span><button class=\"cc-edit\" onclick=\"_wiz.jumpTo(3)\">修改</button></div>\n <div class=\"cc-body\">\n <div style=\"font-weight:600; font-size:13px;\">${esc(state.projectName)}</div>\n <div class=\"ln\"><b>${esc(styleObj.name)}</b> · ${esc(personaObj.name)} · ${esc(personaObj.sub)}</div>\n <div class=\"ln\" style=\"font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48);\">卖点:${esc(pointsList)}</div>\n </div>\n </div>\n\n <div class=\"confirm-card\">\n <div class=\"cc-h\"><span>// 输出参数</span></div>\n <div class=\"cc-body\">\n <div class=\"ln\"><b>${esc(durObj.label)}</b> · <b>${durObj.shots[0]}-${durObj.shots[1]} 镜</b> · 9:16</div>\n <div class=\"ln\">预估完播 <b>${durObj.completion}%</b> · 预估转化 <b>${durObj.conversion}%</b></div>\n <div class=\"ln\" style=\"font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48);\">// 数据来源:抖音同品类 TOP 均值</div>\n </div>\n </div>\n </div>\n\n <div class=\"section-sub\">计费明细 · 按量计费</div>\n <div class=\"bill-list\">\n <div class=\"bill-row\"><div class=\"l\">脚本生成 <span class=\"l-sub\">LLM · 1 稿</span></div><div class=\"qty\">× 1</div><div class=\"amt\">¥${c.script}</div></div>\n <div class=\"bill-row\"><div class=\"l\">故事板生成 <span class=\"l-sub\">含分镜画面描述</span></div><div class=\"qty\">× 1</div><div class=\"amt\">¥${c.sb}</div></div>\n <div class=\"bill-row\"><div class=\"l\">资产生成 <span class=\"l-sub\">主图 → 镜头素材</span></div><div class=\"qty\">× ${p ? p.imgs : 0} 张</div><div class=\"amt\">¥${c.assets}</div></div>\n <div class=\"bill-row\"><div class=\"l\">视频渲染 <span class=\"l-sub\">合成 · 配乐 · 字幕</span></div><div class=\"qty\">× ${getShots()} 镜</div><div class=\"amt\">¥${c.render}</div></div>\n <div class=\"bill-row subtotal\"><div class=\"l\">小计</div><div class=\"qty\"></div><div class=\"amt\">¥${c.subtotal}</div></div>\n <div class=\"bill-row subtotal\"><div class=\"l\">平台服务费 <span class=\"l-sub\">5%</span></div><div class=\"qty\"></div><div class=\"amt\">¥${c.fee}</div></div>\n <div class=\"bill-row total\"><div class=\"l\">合计</div><div class=\"qty\"></div><div class=\"amt\">¥${Math.floor(c.total)}<small>.${c.total.toFixed(2).split('.')[1]}</small></div></div>\n </div>\n\n <div class=\"balance-row${low ? ' low' : ''}\">\n <div class=\"bl\">\n ${ICONS.wallet}\n <span class=\"lbl\">账户余额</span>\n <span class=\"val\">¥${ACCOUNT_BALANCE.toFixed(2)}</span>\n <span class=\"arrow\">→</span>\n <span class=\"lbl\">扣款后</span>\n <span class=\"val after\">¥${ba.toFixed(2)}</span>\n </div>\n ${low\n ? `<span class=\"pill err\"><span class=\"dot\"></span>余额不足 · <a>去充值</a></span>`\n : `<span class=\"pill ok\"><span class=\"dot\"></span>余额充足</span>`}\n </div>\n\n <div class=\"section-sub\">预估耗时 · 通知</div>\n <div class=\"eta-block\">\n <div class=\"eta-tile\">\n <div class=\"lbl\">预估出片</div>\n <div class=\"v\">~ ${eta}<small>分钟</small></div>\n <div class=\"desc\">// pipeline 5 阶段累计 · 不含人工审核</div>\n </div>\n <div class=\"eta-tile\">\n <div class=\"lbl\">完成后通知</div>\n <div class=\"check-row${state.notifyEmail ? ' on' : ''}\" onclick=\"_wiz.toggleEmail()\" style=\"padding:4px 0;\">\n <span class=\"check-box\"></span>\n <span class=\"lab\">邮件 <span class=\"mono\">${esc(USER_EMAIL)}</span></span>\n </div>\n <div class=\"check-row${state.notifyWeChat ? ' on' : ''}\" onclick=\"_wiz.toggleWeChat()\" style=\"padding:4px 0;\">\n <span class=\"check-box\"></span>\n <span class=\"lab\">微信 <span class=\"mono\">未绑定 · 去绑定</span></span>\n </div>\n </div>\n </div>\n\n <div class=\"tos-row${state.agreed ? ' on' : ''}\" onclick=\"_wiz.toggleTos()\">\n <span class=\"check-box\"></span>\n <span class=\"lab\">我已阅读并同意 <a>《按量计费协议》</a> 与 <a>《商品素材使用授权》</a></span>\n </div>\n </div>`;\n }\n\n function renderCollapsedStep(n) {\n const p = getProduct(), s = getSource();\n let title = '', body = '';\n if (n === 1) {\n title = '第 1 步 · 选择商品';\n body = p\n ? `<div style=\"display:flex; gap:12px; align-items:flex-start;\">\n <div class=\"placeholder\" style=\"width:44px; height:56px; flex-shrink:0;\"><span class=\"ph-frame\">9:16</span></div>\n <div style=\"min-width:0;\">\n <div style=\"font-weight:600; font-size:13.5px;\">${esc(p.name)}</div>\n <div class=\"mono\" style=\"font-size:11.5px; color: var(--black-alpha-48); margin-top:3px; letter-spacing:.02em;\">${esc(p.cat)}${p.price != null ? ' · ¥' + p.price : ''} · ${p.imgs} 张图 · ${p.points.length} 个卖点</div>\n </div>\n </div>`\n : '<span class=\"mono\" style=\"color: var(--black-alpha-48); font-size: 11.5px;\">未选择</span>';\n } else if (n === 2) {\n title = '第 2 步 · 脚本来源';\n if (s) {\n let extra = '';\n if (s.id === 'theme' && state.themeText) {\n extra = `<span class=\"muted\" style=\"color: var(--black-alpha-56);\">主题:</span><span style=\"font-size: 13px;\">${esc(state.themeText)}</span>`;\n } else if (s.id === 'manual') {\n extra = `<span class=\"muted\" style=\"color: var(--black-alpha-56);\">脚本:</span><span style=\"font-size: 13px;\">${state.manualScript.length} 字</span>`;\n } else {\n extra = `<span class=\"mono\" style=\"font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em;\">// 走向由 Step 3 决定</span>`;\n }\n body = `<div style=\"display:flex; gap:8px; align-items:center; flex-wrap:wrap;\">\n <span class=\"pill info\" style=\"display:inline-flex; align-items:center; gap:6px; padding: 4px 10px; border-radius: 999px; font-size: 11.5px; background: var(--heat-12); color: var(--heat); border: 1px solid var(--heat-20); font-weight: 500;\"><span class=\"dot\" style=\"width:6px;height:6px;border-radius:50%;background:currentColor;\"></span>${esc(s.name)}</span>\n ${extra}\n </div>`;\n } else {\n body = '<span class=\"mono\" style=\"color: var(--black-alpha-48); font-size: 11.5px;\">未选择</span>';\n }\n }\n return `<div class=\"wiz-pane collapsed\">\n <div class=\"wiz-pane-h\">\n <h3>${esc(title)}</h3>\n <span style=\"flex:1\"></span>\n <button class=\"btn btn-ghost btn-sm\" onclick=\"_wiz.jumpTo(${n})\">修改</button>\n </div>\n ${body}\n </div>`;\n }\n\n function renderFoot() {\n const cfg = railConfig();\n const last = state.currentStep === 4;\n const passOk = state.currentStep === 1 ? canPass1()\n : state.currentStep === 2 ? canPass2()\n : state.currentStep === 3 ? canPass3()\n : canFinish();\n const nextLabel = last ? '开始生成 →' : '下一步 →';\n const hint = last\n ? `// 扣款 ¥${getCost().total.toFixed(2)} · 进入 pipeline`\n : `// 下一步:${cfg[state.currentStep].label}`;\n const action = last ? '_wiz.startGenerate()' : '_wiz.goNext()';\n return `<div class=\"wiz-foot\">\n <button class=\"btn btn-ghost\"${state.currentStep === 1 ? ' disabled' : ''} onclick=\"_wiz.goPrev()\">← 上一步</button>\n <div style=\"display:flex; align-items:center; gap:12px;\">\n <span class=\"mono\" style=\"font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em;\">${esc(hint)}</span>\n <button class=\"btn btn-primary btn-lg${!passOk ? ' disabled' : ''}\" onclick=\"${action}\">${nextLabel}</button>\n </div>\n </div>`;\n }\n\n function renderPreview() {\n // 实时预估面板已移除 (改为底部「开始」CTA)\n const previewEl = $('#preview');\n if (previewEl) previewEl.innerHTML = '';\n return;\n // legacy code below kept for reference but unreachable\n const p = getProduct(), personaObj = getPersona(), durObj = getDuration(), styleObj = getStyle(), c = getCost();\n const shots = getShots();\n const pointsOn = Object.entries(state.points).filter(([, v]) => v).map(([k]) => k);\n const title = state.projectName || (p ? p.name + ' · 待命名' : '未命名项目');\n\n const arrows = '<span class=\"arrow\">' + ICONS.arrow + '</span>';\n\n const ready = canPass1() && canPass2() && canPass3();\n const balOk = balanceAfter() >= 5;\n const canGo = ready && balOk && state.agreed;\n const footState = !ready ? '填写中' : !balOk ? '余额不足' : !state.agreed ? '待确认协议' : '就绪';\n const stepProgress = [canPass1(), canPass2(), canPass3()].filter(Boolean).length;\n\n const configReady = !!(state.persona && state.duration && state.scriptStyle);\n const summaryBlock = (p || configReady) ? `\n <div class=\"pv-section\">\n <div class=\"lbl\">// 已选</div>\n <ul class=\"pv-list\">\n ${p ? `<li>${esc(p.name)}</li>` : ''}\n ${configReady ? `<li>${esc(personaObj.name)} · ${esc(styleObj.name)} · ${esc(durObj.label)}</li>` : ''}\n </ul>\n </div>` : `\n <div class=\"pv-section\">\n <div class=\"lbl\">// 待选择</div>\n <ul class=\"pv-list\" style=\"opacity: .6;\">\n <li style=\"color: var(--black-alpha-48);\">先选商品 · 预估会自动填充</li>\n </ul>\n </div>`;\n\n // 计费 + 余额(精简版,不展开 5 阶段明细)\n const billingHTML = `\n <div class=\"pv-bill-summary\">\n <div class=\"row\"><span class=\"k\">预估合计</span><span class=\"v\"><b>¥${c.total.toFixed(2)}</b></span></div>\n <div class=\"row\"><span class=\"k\">扣款后余额</span><span class=\"v ${balOk ? '' : 'low'}\">¥${balanceAfter().toFixed(2)}</span></div>\n </div>\n <div class=\"pv-agree-row\">\n <label>\n <input type=\"checkbox\" id=\"pv-agree\" ${state.agreed ? 'checked' : ''} onchange=\"_wiz.toggleAgree(this.checked)\">\n <span>同意 <a href=\"#\" onclick=\"event.preventDefault();Shell.toast('用户协议')\">协议</a> 与 <a href=\"#\" onclick=\"event.preventDefault();Shell.toast('计费规则')\">计费规则</a>(失败不扣费)</span>\n </label>\n </div>\n `;\n\n $('#preview').innerHTML = `\n <div class=\"pv-h\"><span>实时预估</span><span class=\"live\">LIVE</span></div>\n <div class=\"pv-title\">${esc(title)}</div>\n <div class=\"pv-metrics\">\n <div class=\"pv-metric\"><div class=\"l\">镜头</div><div class=\"v\">${shots}<small>镜</small></div></div>\n <div class=\"pv-metric accent\"><div class=\"l\">预估完播</div><div class=\"v\">${durObj.completion}<small>%</small></div></div>\n <div class=\"pv-metric\"><div class=\"l\">预估转化</div><div class=\"v\">${durObj.conversion}<small>%</small></div></div>\n <div class=\"pv-metric\"><div class=\"l\">预估成本</div><div class=\"v\">¥${c.total.toFixed(2)}</div></div>\n </div>\n ${summaryBlock}\n ${billingHTML}\n <button class=\"btn btn-primary btn-lg\" style=\"width:100%;margin-top:14px;justify-content:center;${canGo ? '' : 'opacity:.5;pointer-events:none;cursor:not-allowed;'}\" onclick=\"_wiz.startGenerate()\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 3l14 9-14 9V3z\"/></svg>\n 开始生成\n </button>\n <div class=\"pv-foot\">\n <span>${stepProgress}/3 步已完成</span>\n <strong>${footState}</strong>\n </div>\n `;\n }\n\n /* ---------- partial updates (to keep inputs from losing focus) ---------- */\n\n function renderStep1Only() {\n // when user types in search or clicks cat chip — only re-render Step 1 main area\n if (state.currentStep !== 1) return;\n const body = $('#wiz-body');\n const active = body.querySelector('.wiz-pane.active');\n if (active) {\n const tmp = document.createElement('div');\n tmp.innerHTML = renderStep1();\n active.replaceWith(tmp.firstElementChild);\n }\n // refocus search input (旧 .search-input → 新 .pp-toolbar .search-inline)\n const inp = body.querySelector('.pp-toolbar .search-inline input')\n || body.querySelector('.search-input input');\n if (inp && state.pickSearch && document.activeElement !== inp) {\n inp.focus();\n const v = inp.value;\n try { inp.setSelectionRange(v.length, v.length); } catch (e) {}\n }\n }\n\n function updatePreviewLive() { renderPreview(); }\n function updateFootOnly() {\n const body = $('#wiz-body');\n const foot = body.querySelector('.wiz-foot');\n if (foot) {\n const tmp = document.createElement('div');\n tmp.innerHTML = renderFoot();\n foot.replaceWith(tmp.firstElementChild);\n }\n }\n function updateRailOnly() { renderRail(); }\n\n /* ---------- main render ---------- */\n\n function render() {\n renderRail();\n const body = $('#wiz-body');\n // 单页式: 商品 (step1) + 项目配置 (原 step3, 现 step2),底部「开始」CTA\n const p = getProduct(), du = getDuration(), st = getStyle();\n const canStart = !!(p && du && st && state.projectName.trim().length >= 2);\n let html = '';\n html += '<section id=\"step-pane-1\" class=\"step-pane-wrap\">' + renderStep1() + '</section>';\n html += '<section id=\"step-pane-2\" class=\"step-pane-wrap\">' + renderStep3() + '</section>';\n html += `<div class=\"wiz-start-bar\">\n <button class=\"btn-start${canStart ? '' : ' disabled'}\" type=\"button\" onclick=\"_wiz.startGenerate()\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 3l14 9-14 9V3z\"/></svg>\n <span>开始</span>\n </button>\n </div>`;\n body.innerHTML = html;\n }\n\n // 商品库弹窗事件绑定 (DOM 静态元素)\n document.getElementById('pl-close-btn').addEventListener('click', closeProdLib);\n document.getElementById('pl-cancel-btn').addEventListener('click', closeProdLib);\n document.getElementById('pl-search-input').addEventListener('input', e => {\n _plQuery = e.target.value.trim();\n renderProdLib();\n });\n document.getElementById('pl-new-btn').addEventListener('click', () => {\n // 商品库保持 open(drawer z-index 1101 > pl-modal-bg 998 会覆盖之上)\n openNewProduct();\n });\n\n // 全局点击 → 关闭 picker chip 菜单\n document.addEventListener('click', e => {\n if (!e.target.closest('.pp-chip-wrap')) {\n document.querySelectorAll('.pp-chip-wrap.open').forEach(w => w.classList.remove('open'));\n }\n });\n\n // initial render\n render();\n})();\n</script>\n<script src=\"/exact/assets/api-bridge.js?v=2026060114\"></script>\n</body>\n</html>\n",
|
||
"projects": "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta name=\"x-airshelf-exact-source\" content=\"projects.html\">\n<meta charset=\"utf-8\">\n<title>视频项目 · Airshelf</title>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"/exact/assets/restraint.css?v=2026060101\">\n<style>\n /* ─── List view ─── */\n .proj-name-cell { display: flex; align-items: center; gap: 12px; }\n .proj-thumb { width: 40px; height: 52px; flex-shrink: 0; border-radius: var(--r-md); }\n .proj-name { font-weight: 600; color: var(--accent-black); font-size: 13.5px; }\n .proj-sub { font-size: 11.5px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }\n .row-action { display: flex; gap: 4px; visibility: hidden; }\n table.t tbody tr:hover .row-action { visibility: visible; }\n .row-action a { width: 28px; height: 28px; display: grid; place-items: center; color: var(--black-alpha-56); border-radius: var(--r-md); }\n .row-action a:hover { background: var(--surface); color: var(--heat); border: 1px solid var(--border-faint); }\n\n /* ─── View toggle ─── */\n .view-toggle { display: inline-flex; border: 1px solid var(--border-faint); border-radius: var(--r-md); overflow: hidden; }\n .view-toggle button { padding: 0 14px; background: var(--surface); color: var(--black-alpha-56); font-size: 13px; border-right: 1px solid var(--border-faint); border-radius: 0; height: 36px; cursor: pointer; font-family: inherit; display: flex; align-items: center; gap: 6px; transition: background var(--t-base), color var(--t-base); }\n .view-toggle button:last-child { border-right: 0; }\n .view-toggle button:hover { background: var(--background-lighter); color: var(--accent-black); }\n .view-toggle button.active { background: var(--heat-12); color: var(--heat); font-weight: 600; }\n .view-toggle button svg { width: 13px; height: 13px; }\n\n /* ─── Grid view ─── */\n .proj-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }\n .proj-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background .15s; display: flex; flex-direction: column; position: relative; }\n .proj-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }\n .proj-card:hover .card-del-btn { opacity: 1; }\n .proj-card .card-thumb { aspect-ratio: 9/16; max-height: 280px; border-radius: var(--r-md) var(--r-md) 0 0; }\n /* 编辑模式 checkbox */\n .proj-card .card-check {\n position: absolute; top: 10px; left: 10px;\n width: 22px; height: 22px; border-radius: 50%;\n background: var(--surface); border: 2px solid var(--black-alpha-32);\n display: none; place-items: center;\n color: var(--accent-white); z-index: 5;\n pointer-events: none;\n }\n .proj-card .card-check svg { width: 11px; height: 11px; opacity: 0; }\n body.edit-mode .proj-card .card-check { display: grid; }\n body.edit-mode .proj-card.selected .card-check {\n background: var(--heat); border-color: var(--heat);\n }\n body.edit-mode .proj-card.selected .card-check svg { opacity: 1; }\n body.edit-mode .proj-card.selected { border-color: var(--heat); box-shadow: 0 0 0 1px var(--heat) inset; }\n body.edit-mode .proj-card .card-del-btn { opacity: 0 !important; pointer-events: none !important; }\n /* 列表行末 ⋯ 删除气泡 */\n .row-more {\n position: relative; display: inline-flex;\n cursor: pointer; align-items: center;\n color: var(--black-alpha-56);\n padding: 4px;\n }\n .row-more:hover { color: var(--accent-black); }\n .row-more-tip {\n position: absolute; top: calc(100% + 6px); right: 0;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n box-shadow: 0 4px 16px rgba(0,0,0,.08);\n padding: 4px; min-width: 110px;\n opacity: 0; pointer-events: none;\n transform: translateY(-2px);\n transition: opacity .15s, transform .15s;\n z-index: 12;\n }\n .row-more-tip::before {\n content: ''; position: absolute;\n top: -8px; left: 0; right: 0; height: 8px;\n }\n .row-more:hover .row-more-tip,\n .row-more-tip:hover { opacity: 1; pointer-events: auto; transform: translateY(0); }\n .row-more-tip .mi {\n display: flex; align-items: center; gap: 6px;\n width: 100%; padding: 6px 10px;\n background: transparent; border: 0;\n border-radius: var(--r-sm); cursor: pointer;\n font-size: 12.5px; color: var(--accent-black);\n font-family: inherit; text-align: left;\n transition: background var(--t-base), color var(--t-base);\n }\n .row-more-tip .mi:hover {\n background: var(--crimson-bg, #fdebea);\n color: var(--accent-crimson, #c43d3d);\n }\n .row-more-tip .mi svg { width: 13px; height: 13px; }\n .btn.active { background: var(--accent-black); color: var(--accent-white); border-color: var(--accent-black); }\n /* bulk-bar */\n .bulk-bar {\n position: fixed; bottom: 24px; left: 50%;\n transform: translateX(-50%);\n background: var(--accent-black); color: var(--accent-white);\n border-radius: var(--r-md);\n padding: 10px 14px 10px 18px;\n display: none; align-items: center; gap: 16px;\n box-shadow: 0 8px 24px rgba(0,0,0,.18);\n z-index: 100; font-size: 13px;\n }\n body.edit-mode .bulk-bar { display: inline-flex; }\n .bulk-bar .ct { font-family: var(--font-mono); letter-spacing: .02em; }\n .bulk-bar .ct b { color: var(--heat); font-weight: 700; padding: 0 3px; }\n .bulk-bar .sep { width: 1px; height: 18px; background: rgba(255,255,255,.16); }\n .bulk-bar button {\n height: 30px; padding: 0 12px;\n background: transparent; border: 1px solid rgba(255,255,255,.24);\n border-radius: var(--r-sm); color: var(--accent-white);\n font-size: 12.5px; font-family: inherit; cursor: pointer;\n display: inline-flex; align-items: center; gap: 5px;\n }\n .bulk-bar button:hover { background: rgba(255,255,255,.08); }\n .bulk-bar button.danger { background: var(--accent-crimson); border-color: var(--accent-crimson); }\n .bulk-bar button svg { width: 12px; height: 12px; }\n .bulk-bar .clear-sel { color: rgba(255,255,255,.6); font-size: 12px; cursor: pointer; background: none; border: 0; padding: 4px 6px; }\n .proj-card .card-body { padding: 14px; display: flex; flex-direction: column; gap: 10px; flex: 1; }\n .proj-card .card-name { font-size: 13.5px; font-weight: 600; color: var(--accent-black); line-height: 1.4; }\n .proj-card .card-sub { font-size: 11.5px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; }\n .proj-card .card-foot { display: flex; align-items: center; justify-content: space-between; padding-top: 10px; border-top: 1px solid var(--border-faint); margin-top: auto; }\n .proj-card .card-time { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }\n\n</style>\n</head>\n<body>\n<div id=\"page\">\n\n<div class=\"page-head\">\n <div>\n <h1>视频项目</h1>\n <div class=\"sub\"><span class=\"mono\">// <span id=\"sub-total\">0</span> 个 · <span id=\"sub-wip\">0</span> 进行中 · <span id=\"sub-done\">0</span> 完成 · <span id=\"sub-fail\">0</span> 失败</span></div>\n </div>\n <div class=\"actions\">\n <button class=\"btn\" type=\"button\" id=\"proj-manage-btn\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m3 7 2 2 4-4\"/><path d=\"m3 17 2 2 4-4\"/><path d=\"M13 6h8\"/><path d=\"M13 12h8\"/><path d=\"M13 18h8\"/></svg>\n <span class=\"proj-manage-label\">管理项目</span>\n </button>\n <a class=\"btn btn-primary btn-lg btn-create\" href=\"projects-new.html\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m12.3 3.5 3 4\"/><path d=\"M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z\"/><path d=\"m6.2 5.3 3.1 3.9\"/><path d=\"M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z\"/></svg>\n 新建项目\n </a>\n </div>\n</div>\n\n<div class=\"tabs\" id=\"status-tabs\">\n <div class=\"tab active\" data-filter=\"all\">全部 <span class=\"count\">0</span></div>\n <div class=\"tab\" data-filter=\"wip\">进行中 <span class=\"count\">0</span></div>\n <div class=\"tab\" data-filter=\"done\">已完成 <span class=\"count\">0</span></div>\n <div class=\"tab\" data-filter=\"fail\">失败 <span class=\"count\">0</span></div>\n</div>\n\n<div class=\"toolbar\">\n <div class=\"search-inline\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/></svg>\n <input class=\"input\" id=\"search-input\" placeholder=\"搜索项目名称、商品\">\n </div>\n <div class=\"chip-wrap\" data-key=\"product\">\n <button class=\"chip\" type=\"button\"><span class=\"chip-label\">商品品类</span> <svg class=\"caret\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></button>\n <div class=\"chip-menu\"></div>\n </div>\n <div class=\"chip-wrap\" data-key=\"source\">\n <button class=\"chip\" type=\"button\"><span class=\"chip-label\">脚本来源</span> <svg class=\"caret\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></button>\n <div class=\"chip-menu\"></div>\n </div>\n <div class=\"chip-wrap\" data-key=\"time\">\n <button class=\"chip\" type=\"button\"><span class=\"chip-label\">创建时间</span> <svg class=\"caret\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 6l4 4 4-4\"/></svg></button>\n <div class=\"chip-menu\"></div>\n </div>\n <button class=\"clear-filters\" id=\"clear-filters\" type=\"button\" hidden>\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M4 4l8 8M12 4l-8 8\"/></svg>\n 清空筛选\n </button>\n <span class=\"spacer\"></span>\n <div class=\"view-toggle\">\n <button id=\"view-grid\" data-view=\"grid\">\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><rect x=\"2\" y=\"2\" width=\"5\" height=\"5\"/><rect x=\"9\" y=\"2\" width=\"5\" height=\"5\"/><rect x=\"2\" y=\"9\" width=\"5\" height=\"5\"/><rect x=\"9\" y=\"9\" width=\"5\" height=\"5\"/></svg>\n 网格\n </button>\n <button id=\"view-list\" class=\"active\" data-view=\"list\">\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M2 4h12M2 8h12M2 12h12\"/></svg>\n 列表\n </button>\n </div>\n</div>\n\n<div class=\"result-meta\" id=\"result-meta\">// 显示 <span class=\"count\">12</span> / 12 个项目</div>\n\n<!-- ============= LIST VIEW ============= -->\n<div id=\"list-view\">\n <table class=\"t\">\n <thead>\n <tr>\n <th style=\"width:32%\">项目</th>\n <th>商品</th>\n <th>脚本来源</th>\n <th style=\"width:200px\">进度</th>\n <th>状态</th>\n <th style=\"width:120px\">更新于</th>\n <th style=\"width:60px\"></th>\n </tr>\n </thead>\n <tbody id=\"list-tbody\">\n <tr data-status=\"wip\" data-name=\"补水面膜 痛点种草\" onclick=\"location.href='pipeline.html#stage-3'\">\n <td>\n <div class=\"proj-name-cell\">\n <div class=\"placeholder proj-thumb\"><span class=\"ph-frame\">9:16</span></div>\n <div><div class=\"proj-name\">补水面膜 · 痛点种草 · v3</div><div class=\"proj-sub\">6 镜 · 0-15s</div></div>\n </div>\n </td>\n <td>透真补水面膜</td>\n <td><span class=\"muted\">AI 全生</span></td>\n <td>\n <div class=\"hstack\">\n <div class=\"prog\"><span class=\"done\"></span><span class=\"done\"></span><span class=\"cur\"></span><span></span><span></span></div>\n <span class=\"muted-2 mono\" style=\"font-size:11px;\">3/5</span>\n </div>\n </td>\n <td><span class=\"pill info\"><span class=\"dot\"></span>故事板生成中</span></td>\n <td class=\"muted-2\">12 分钟前</td>\n <td>\n <div class=\"row-action\">\n <a href=\"pipeline.html#stage-3\" onclick=\"event.stopPropagation()\" title=\"继续\"><svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\"><path d=\"M5 4l6 4-6 4z\" fill=\"currentColor\"/></svg></a>\n <span class=\"row-more\" onclick=\"event.stopPropagation()\"><svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\"><circle cx=\"3\" cy=\"8\" r=\"1.2\" fill=\"currentColor\"/><circle cx=\"8\" cy=\"8\" r=\"1.2\" fill=\"currentColor\"/><circle cx=\"13\" cy=\"8\" r=\"1.2\" fill=\"currentColor\"/></svg><div class=\"row-more-tip\"><button class=\"mi mi-del-row\" type=\"button\" onclick=\"event.stopPropagation();\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg>删除项目</button></div></span>\n </div>\n </td>\n </tr>\n <tr data-status=\"wip\" data-name=\"速食牛肉面 加班治愈\" onclick=\"location.href='pipeline.html#stage-2'\">\n <td>\n <div class=\"proj-name-cell\">\n <div class=\"placeholder proj-thumb\"><span class=\"ph-frame\">9:16</span></div>\n <div><div class=\"proj-name\">速食牛肉面 · 加班治愈</div><div class=\"proj-sub\">4 镜 · 0-12s</div></div>\n </div>\n </td>\n <td>滋啦速食 · 6 桶装</td>\n <td><span class=\"muted\">一句话主题</span></td>\n <td>\n <div class=\"hstack\">\n <div class=\"prog\"><span class=\"done\"></span><span class=\"cur\"></span><span></span><span></span><span></span></div>\n <span class=\"muted-2 mono\" style=\"font-size:11px;\">2/5</span>\n </div>\n </td>\n <td><span class=\"pill info\"><span class=\"dot\"></span>资产生成中</span></td>\n <td class=\"muted-2\">37 分钟前</td>\n <td></td>\n </tr>\n <tr data-status=\"wip\" data-name=\"透真防晒 通勤对比\" onclick=\"location.href='pipeline.html#stage-4'\">\n <td>\n <div class=\"proj-name-cell\">\n <div class=\"placeholder proj-thumb\"><span class=\"ph-frame\">9:16</span></div>\n <div><div class=\"proj-name\">透真防晒 · 通勤对比</div><div class=\"proj-sub\">6 镜 · 0-18s</div></div>\n </div>\n </td>\n <td>透真清透防晒霜</td>\n <td><span class=\"muted\">AI 全生</span></td>\n <td>\n <div class=\"hstack\">\n <div class=\"prog\"><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span><span class=\"cur\"></span><span></span></div>\n <span class=\"muted-2 mono\" style=\"font-size:11px;\">4/5</span>\n </div>\n </td>\n <td><span class=\"pill info\"><span class=\"dot\"></span>视频生成 4/6</span></td>\n <td class=\"muted-2\">2 小时前</td>\n <td></td>\n </tr>\n <tr data-status=\"fail\" data-name=\"咖啡冻干 早八剧情\" onclick=\"location.href='pipeline.html#stage-3'\">\n <td>\n <div class=\"proj-name-cell\">\n <div class=\"placeholder proj-thumb\"><span class=\"ph-frame\">9:16</span></div>\n <div><div class=\"proj-name\">咖啡冻干 · 早八剧情</div><div class=\"proj-sub\">5 镜 · 0-15s</div></div>\n </div>\n </td>\n <td>三顿半同款冻干</td>\n <td><span class=\"muted\">一句话主题</span></td>\n <td>\n <div class=\"hstack\">\n <div class=\"prog\"><span class=\"done\"></span><span class=\"done\"></span><span class=\"fail\"></span><span></span><span></span></div>\n <span class=\"muted-2 mono\" style=\"font-size:11px;\">3/5</span>\n </div>\n </td>\n <td><span class=\"pill err\"><span class=\"dot\"></span>故事板生成失败</span></td>\n <td class=\"muted-2\">昨天 18:42</td>\n <td></td>\n </tr>\n <tr data-status=\"done\" data-name=\"蓝牙耳机 开箱测评\" onclick=\"location.href='pipeline.html#stage-5'\">\n <td>\n <div class=\"proj-name-cell\">\n <div class=\"placeholder proj-thumb\"><span class=\"ph-frame\">9:16</span></div>\n <div><div class=\"proj-name\">蓝牙耳机 · 开箱测评</div><div class=\"proj-sub\">5 镜 · 0-15s</div></div>\n </div>\n </td>\n <td>南卡 Lite Pro</td>\n <td><span class=\"muted\">自带脚本</span></td>\n <td>\n <div class=\"hstack\">\n <div class=\"prog\"><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span></div>\n <span class=\"muted-2 mono\" style=\"font-size:11px;\">5/5</span>\n </div>\n </td>\n <td><span class=\"pill ok\"><span class=\"dot\"></span>已完成</span></td>\n <td class=\"muted-2\">5 月 7 日</td>\n <td></td>\n </tr>\n <tr data-status=\"done\" data-name=\"瑜伽裤 通勤穿搭\" onclick=\"location.href='pipeline.html#stage-5'\">\n <td>\n <div class=\"proj-name-cell\">\n <div class=\"placeholder proj-thumb\"><span class=\"ph-frame\">9:16</span></div>\n <div><div class=\"proj-name\">瑜伽裤 · 通勤穿搭</div><div class=\"proj-sub\">5 镜 · 0-15s</div></div>\n </div>\n </td>\n <td>露露同款瑜伽裤</td>\n <td><span class=\"muted\">AI 全生</span></td>\n <td>\n <div class=\"hstack\">\n <div class=\"prog\"><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span></div>\n <span class=\"muted-2 mono\" style=\"font-size:11px;\">5/5</span>\n </div>\n </td>\n <td><span class=\"pill ok\"><span class=\"dot\"></span>已完成</span></td>\n <td class=\"muted-2\">5 月 6 日</td>\n <td></td>\n </tr>\n <tr data-status=\"done\" data-name=\"空气炸锅 小户型\" onclick=\"location.href='pipeline.html#stage-5'\">\n <td>\n <div class=\"proj-name-cell\">\n <div class=\"placeholder proj-thumb\"><span class=\"ph-frame\">9:16</span></div>\n <div><div class=\"proj-name\">空气炸锅 · 小户型</div><div class=\"proj-sub\">4 镜 · 0-12s</div></div>\n </div>\n </td>\n <td>小熊 4L 空气炸锅</td>\n <td><span class=\"muted\">一句话主题</span></td>\n <td>\n <div class=\"hstack\">\n <div class=\"prog\"><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span></div>\n <span class=\"muted-2 mono\" style=\"font-size:11px;\">5/5</span>\n </div>\n </td>\n <td><span class=\"pill ok\"><span class=\"dot\"></span>已完成</span></td>\n <td class=\"muted-2\">5 月 4 日</td>\n <td></td>\n </tr>\n </tbody>\n </table>\n</div>\n\n<!-- ============= GRID VIEW ============= -->\n<div id=\"grid-view\" style=\"display:none;\">\n <div class=\"proj-grid\" id=\"grid-body\">\n <div class=\"proj-card\" data-status=\"wip\" data-name=\"补水面膜 痛点种草\" onclick=\"location.href='pipeline.html#stage-3'\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除项目\" onclick=\"event.stopPropagation();\" data-action=\"delete-project\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder card-thumb\"><span class=\"ph-frame\">9:16 · 镜 3/6</span></div>\n <div class=\"card-body\">\n <div>\n <div class=\"card-name\">补水面膜 · 痛点种草 · v3</div>\n <div class=\"card-sub\" style=\"margin-top:4px;\">透真补水面膜 · 6 镜</div>\n </div>\n <div class=\"hstack\">\n <div class=\"prog\"><span class=\"done\"></span><span class=\"done\"></span><span class=\"cur\"></span><span></span><span></span></div>\n <span class=\"muted-2 mono\" style=\"font-size:10.5px;\">3/5</span>\n </div>\n <div class=\"card-foot\">\n <span class=\"pill info\"><span class=\"dot\"></span>故事板生成中</span>\n <span class=\"card-time\">12 分钟前</span>\n </div>\n </div>\n </div>\n\n <div class=\"proj-card\" data-status=\"wip\" data-name=\"速食牛肉面 加班治愈\" onclick=\"location.href='pipeline.html#stage-2'\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除项目\" onclick=\"event.stopPropagation();\" data-action=\"delete-project\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder card-thumb\"><span class=\"ph-frame\">9:16 · 镜 2/4</span></div>\n <div class=\"card-body\">\n <div>\n <div class=\"card-name\">速食牛肉面 · 加班治愈</div>\n <div class=\"card-sub\" style=\"margin-top:4px;\">滋啦速食 · 4 镜</div>\n </div>\n <div class=\"hstack\">\n <div class=\"prog\"><span class=\"done\"></span><span class=\"cur\"></span><span></span><span></span><span></span></div>\n <span class=\"muted-2 mono\" style=\"font-size:10.5px;\">2/5</span>\n </div>\n <div class=\"card-foot\">\n <span class=\"pill info\"><span class=\"dot\"></span>资产生成中</span>\n <span class=\"card-time\">37 分钟前</span>\n </div>\n </div>\n </div>\n\n <div class=\"proj-card\" data-status=\"wip\" data-name=\"透真防晒 通勤对比\" onclick=\"location.href='pipeline.html#stage-4'\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除项目\" onclick=\"event.stopPropagation();\" data-action=\"delete-project\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder card-thumb\"><span class=\"ph-frame\">9:16 · 镜 4/6</span></div>\n <div class=\"card-body\">\n <div>\n <div class=\"card-name\">透真防晒 · 通勤对比</div>\n <div class=\"card-sub\" style=\"margin-top:4px;\">透真清透防晒霜 · 6 镜</div>\n </div>\n <div class=\"hstack\">\n <div class=\"prog\"><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span><span class=\"cur\"></span><span></span></div>\n <span class=\"muted-2 mono\" style=\"font-size:10.5px;\">4/5</span>\n </div>\n <div class=\"card-foot\">\n <span class=\"pill info\"><span class=\"dot\"></span>视频 4/6</span>\n <span class=\"card-time\">2 小时前</span>\n </div>\n </div>\n </div>\n\n <div class=\"proj-card\" data-status=\"fail\" data-name=\"咖啡冻干 早八剧情\" onclick=\"location.href='pipeline.html#stage-3'\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除项目\" onclick=\"event.stopPropagation();\" data-action=\"delete-project\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder card-thumb\"><span class=\"ph-frame\">9:16 · 镜 3/5</span></div>\n <div class=\"card-body\">\n <div>\n <div class=\"card-name\">咖啡冻干 · 早八剧情</div>\n <div class=\"card-sub\" style=\"margin-top:4px;\">三顿半同款 · 5 镜</div>\n </div>\n <div class=\"hstack\">\n <div class=\"prog\"><span class=\"done\"></span><span class=\"done\"></span><span class=\"fail\"></span><span></span><span></span></div>\n <span class=\"muted-2 mono\" style=\"font-size:10.5px;\">3/5</span>\n </div>\n <div class=\"card-foot\">\n <span class=\"pill err\"><span class=\"dot\"></span>故事板失败</span>\n <span class=\"card-time\">昨天 18:42</span>\n </div>\n </div>\n </div>\n\n <div class=\"proj-card\" data-status=\"done\" data-name=\"蓝牙耳机 开箱测评\" onclick=\"location.href='pipeline.html#stage-5'\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除项目\" onclick=\"event.stopPropagation();\" data-action=\"delete-project\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder card-thumb\"><span class=\"ph-frame\">9:16 · 5/5 ✓</span></div>\n <div class=\"card-body\">\n <div>\n <div class=\"card-name\">蓝牙耳机 · 开箱测评</div>\n <div class=\"card-sub\" style=\"margin-top:4px;\">南卡 Lite Pro · 5 镜</div>\n </div>\n <div class=\"hstack\">\n <div class=\"prog\"><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span></div>\n <span class=\"muted-2 mono\" style=\"font-size:10.5px;\">5/5</span>\n </div>\n <div class=\"card-foot\">\n <span class=\"pill ok\"><span class=\"dot\"></span>已完成</span>\n <span class=\"card-time\">5 月 7 日</span>\n </div>\n </div>\n </div>\n\n <div class=\"proj-card\" data-status=\"done\" data-name=\"瑜伽裤 通勤穿搭\" onclick=\"location.href='pipeline.html#stage-5'\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除项目\" onclick=\"event.stopPropagation();\" data-action=\"delete-project\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder card-thumb\"><span class=\"ph-frame\">9:16 · 5/5 ✓</span></div>\n <div class=\"card-body\">\n <div>\n <div class=\"card-name\">瑜伽裤 · 通勤穿搭</div>\n <div class=\"card-sub\" style=\"margin-top:4px;\">露露同款 · 5 镜</div>\n </div>\n <div class=\"hstack\">\n <div class=\"prog\"><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span></div>\n <span class=\"muted-2 mono\" style=\"font-size:10.5px;\">5/5</span>\n </div>\n <div class=\"card-foot\">\n <span class=\"pill ok\"><span class=\"dot\"></span>已完成</span>\n <span class=\"card-time\">5 月 6 日</span>\n </div>\n </div>\n </div>\n\n <div class=\"proj-card\" data-status=\"done\" data-name=\"空气炸锅 小户型\" onclick=\"location.href='pipeline.html#stage-5'\">\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除项目\" onclick=\"event.stopPropagation();\" data-action=\"delete-project\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder card-thumb\"><span class=\"ph-frame\">9:16 · 5/5 ✓</span></div>\n <div class=\"card-body\">\n <div>\n <div class=\"card-name\">空气炸锅 · 小户型</div>\n <div class=\"card-sub\" style=\"margin-top:4px;\">小熊 4L · 4 镜</div>\n </div>\n <div class=\"hstack\">\n <div class=\"prog\"><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span><span class=\"done\"></span></div>\n <span class=\"muted-2 mono\" style=\"font-size:10.5px;\">5/5</span>\n </div>\n <div class=\"card-foot\">\n <span class=\"pill ok\"><span class=\"dot\"></span>已完成</span>\n <span class=\"card-time\">5 月 4 日</span>\n </div>\n </div>\n </div>\n\n </div>\n</div>\n\n<!-- Empty state -->\n<div class=\"empty-state\" id=\"empty\">\n <div class=\"ic-empty\">\n <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/></svg>\n </div>\n <h3>没有匹配的项目</h3>\n <p>// 试试切换 tab 或修改搜索词</p>\n</div>\n\n<!-- ===== 删除确认 modal (必须在 <script> 之前 · 否则 getElementById 取 null 导致脚本崩) ===== -->\n<div class=\"modal-bg\" id=\"del-confirm-bg\">\n <div class=\"modal\" role=\"dialog\">\n <span class=\"corner-tr\" aria-hidden></span>\n <span class=\"corner-bl\" aria-hidden></span>\n <div class=\"modal-h\">\n <div class=\"ic-m\" style=\"background:var(--crimson-bg,#fdebea);color:var(--accent-crimson,#c43d3d)\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg>\n </div>\n <div class=\"ti\">确认删除项目<span>// CONFIRM DELETE</span></div>\n </div>\n <div class=\"modal-b\" id=\"del-confirm-body\">即将删除项目。</div>\n <div class=\"modal-f\">\n <button class=\"btn\" type=\"button\" id=\"del-confirm-cancel\">取消</button>\n <button class=\"btn\" type=\"button\" id=\"del-confirm-ok\" style=\"background:var(--accent-crimson);color:var(--accent-white);border-color:var(--accent-crimson)\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/></svg>\n 确认删除\n </button>\n </div>\n </div>\n</div>\n\n<!-- ===== bulk-bar (必须在 <script> 之前) ===== -->\n<div class=\"bulk-bar\" id=\"bulk-bar\">\n <span class=\"ct\">已选 <b id=\"bulk-count\">0</b> 项</span>\n <button class=\"clear-sel\" type=\"button\" id=\"bulk-clear\">清空</button>\n <span class=\"sep\"></span>\n <button class=\"danger\" type=\"button\" id=\"bulk-del\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg>\n 删除所选\n </button>\n <button type=\"button\" id=\"bulk-exit\">完成</button>\n</div>\n\n</div>\n<script src=\"/exact/assets/icons.js?v=2026052608\"></script>\n<script src=\"/exact/assets/shell.js?v=2026060101\"></script>\n<script>\nShell.render({ active: 'projects', crumbs: [{ label: '工作台', href: 'index.html' }, { label: '视频项目' }] });\n\n// ============== 注入用户新建的项目 (来自 projects-new.html 向导, 写入 localStorage) ==============\n// 必须在计数 / tagItem / buildMenu 之前执行, 让后续逻辑把它们当作普通项目处理\n(function injectExtraProjects() {\n let pending;\n try {\n pending = JSON.parse(localStorage.getItem('fs-extra-projects') || '[]');\n } catch (e) { return; }\n if (!Array.isArray(pending) || !pending.length) return;\n\n const tbody = document.getElementById('list-tbody');\n const gridBody = document.getElementById('grid-body');\n if (!tbody || !gridBody) return;\n const esc = s => String(s == null ? '' : s).replace(/[&<>\"']/g, c => ({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));\n\n // 按 createdAt 升序 → 倒序 insert,最新的排最上\n pending.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));\n pending.forEach(p => {\n // 链接带 ?product= 让 pipeline 显示正确商品名(贯穿 5 stage)\n const productQ = p.product ? `?product=${encodeURIComponent(p.product)}` : '';\n const href = `pipeline.html${productQ}#stage-${p.stage || 1}`;\n const status = p.status || 'wip';\n const shots = p.shots || 5;\n const durLabel = p.durationLabel || '0-15s';\n const pill = p.pillText || '脚本生成中';\n\n // List row\n const tr = document.createElement('tr');\n tr.dataset.status = status;\n tr.dataset.name = p.name;\n tr.dataset.extraId = p.id;\n tr.setAttribute('onclick', `location.href='${href}'`);\n tr.innerHTML = `\n <td>\n <div class=\"proj-name-cell\">\n <div class=\"placeholder proj-thumb\"><span class=\"ph-frame\">9:16</span></div>\n <div><div class=\"proj-name\">${esc(p.name)}</div><div class=\"proj-sub\">${shots} 镜 · ${esc(durLabel)}</div></div>\n </div>\n </td>\n <td>${esc(p.product)}</td>\n <td><span class=\"muted\">${esc(p.source)}</span></td>\n <td>\n <div class=\"hstack\">\n <div class=\"prog\"><span class=\"cur\"></span><span></span><span></span><span></span><span></span></div>\n <span class=\"muted-2 mono\" style=\"font-size:11px;\">1/5</span>\n </div>\n </td>\n <td><span class=\"pill info\"><span class=\"dot\"></span>${esc(pill)}</span></td>\n <td class=\"muted-2\">刚刚</td>\n <td>\n <div class=\"row-action\">\n <a href=\"${href}\" onclick=\"event.stopPropagation()\" title=\"继续\"><svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\"><path d=\"M5 4l6 4-6 4z\" fill=\"currentColor\"/></svg></a>\n <span class=\"row-more\" onclick=\"event.stopPropagation()\"><svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\"><circle cx=\"3\" cy=\"8\" r=\"1.2\" fill=\"currentColor\"/><circle cx=\"8\" cy=\"8\" r=\"1.2\" fill=\"currentColor\"/><circle cx=\"13\" cy=\"8\" r=\"1.2\" fill=\"currentColor\"/></svg><div class=\"row-more-tip\"><button class=\"mi mi-del-row\" type=\"button\" onclick=\"event.stopPropagation();\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg>删除项目</button></div></span>\n </div>\n </td>\n `;\n tbody.insertBefore(tr, tbody.firstElementChild);\n\n // Grid card\n const card = document.createElement('div');\n card.className = 'proj-card';\n card.dataset.status = status;\n card.dataset.name = p.name;\n card.dataset.extraId = p.id;\n card.setAttribute('onclick', `location.href='${href}'`);\n card.innerHTML = `\n <span class=\"card-check\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg></span>\n <button class=\"card-del-btn\" type=\"button\" title=\"删除项目\" onclick=\"event.stopPropagation();\" data-action=\"delete-project\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg></button>\n <div class=\"placeholder card-thumb\"><span class=\"ph-frame\">9:16 · 刚刚</span></div>\n <div class=\"card-body\">\n <div>\n <div class=\"card-name\">${esc(p.name)}</div>\n <div class=\"card-sub\" style=\"margin-top:4px;\">${esc(p.product)} · ${shots} 镜</div>\n </div>\n <div class=\"hstack\">\n <div class=\"prog\"><span class=\"cur\"></span><span></span><span></span><span></span><span></span></div>\n <span class=\"muted-2 mono\" style=\"font-size:10.5px;\">1/5</span>\n </div>\n <div class=\"card-foot\">\n <span class=\"pill info\"><span class=\"dot\"></span>${esc(pill)}</span>\n <span class=\"card-time\">刚刚</span>\n </div>\n </div>\n `;\n gridBody.insertBefore(card, gridBody.firstElementChild);\n });\n})();\n\n// ============== 从 DOM 实算项目计数,同步副标题 + tab 角标 ==============\nconst allRows = document.querySelectorAll('#list-tbody tr');\nconst counts = { total: allRows.length, wip: 0, done: 0, fail: 0 };\nallRows.forEach(r => { const s = r.dataset.status; if (counts[s] !== undefined) counts[s]++; });\n\ndocument.getElementById('sub-total').textContent = counts.total;\ndocument.getElementById('sub-wip').textContent = counts.wip;\ndocument.getElementById('sub-done').textContent = counts.done;\ndocument.getElementById('sub-fail').textContent = counts.fail;\n\ndocument.querySelectorAll('#status-tabs .tab').forEach(t => {\n const f = t.dataset.filter;\n const n = f === 'all' ? counts.total : (counts[f] || 0);\n t.querySelector('.count').textContent = n;\n});\n\n// ============== Multi-filter state ==============\nconst state = { filter: 'all', view: 'list', search: '', product: 'all', source: 'all', time: 'all' };\n\nconst CHIP_LABELS = { product: '商品品类', source: '脚本来源', time: '创建时间' };\n\n// 抖音爆款品类 · TOP 8\nconst CATEGORIES = ['美妆个护', '服饰内衣', '食品饮料', '家居家电', '数码 3C', '个护清洁', '运动户外', '母婴亲子'];\n\n// 商品名 → 品类(关键字推断,demo 数据覆盖)\nfunction inferCategory(product) {\n const p = product || '';\n if (/面膜|防晒|精华|护肤|彩妆|口红|粉底/.test(p)) return '美妆个护';\n if (/速食|冻干|咖啡|零食|饮料|酒|茶/.test(p)) return '食品饮料';\n if (/耳机|手机|数码|充电|蓝牙|3C/.test(p)) return '数码 3C';\n if (/瑜伽|健身|户外|露营|运动/.test(p)) return '运动户外';\n if (/服装|内衣|裤|衣|鞋|包/.test(p)) return '服饰内衣';\n if (/炸锅|家电|香薰|收纳|床|家居/.test(p)) return '家居家电';\n if (/洗|牙膏|清洁/.test(p)) return '个护清洁';\n if (/婴|童|奶粉|辅食|玩具/.test(p)) return '母婴亲子';\n return '';\n}\n\n// 时间字符串 → 时间桶 (today / week / month / earlier)\nfunction parseTimeBucket(txt) {\n if (!txt) return 'earlier';\n if (txt.includes('分钟前') || txt.includes('小时前')) return 'today';\n if (txt.includes('昨天')) return 'week';\n // \"5 月 7 日\" 等当月日期 → month; 其他月份 → earlier\n const m = txt.match(/(\\d+)\\s*月/);\n if (m) {\n const month = +m[1];\n const now = new Date();\n const curMonth = now.getMonth() + 1;\n if (month === curMonth) return 'month';\n if (month === curMonth - 1) return 'earlier';\n }\n return 'earlier';\n}\nconst TIME_LABEL = { today: '今天', week: '本周', month: '本月', earlier: '更早' };\n\n// 给行/卡片打数据标签 (从已有的列文字推断)\nfunction tagItem(el, productCellText, sourceCellText, timeCellText) {\n const product = (productCellText || '').trim();\n el.dataset.product = product;\n el.dataset.category = inferCategory(product);\n el.dataset.source = (sourceCellText || '').trim();\n el.dataset.time = parseTimeBucket(timeCellText || '');\n}\n\n// List view: 行内列顺序是 项目/商品/脚本来源/进度/状态/更新于\ndocument.querySelectorAll('#list-tbody tr').forEach(tr => {\n const tds = tr.querySelectorAll('td');\n if (tds.length < 6) return;\n tagItem(tr, tds[1].innerText, tds[2].innerText, tds[5].innerText);\n});\n\n// Grid view: 从 card-sub (商品 · N 镜) 抽出商品名,从 card-time 抽时间,无脚本来源信息 → 沿用同名 list 项的 source\nconst listMap = {};\ndocument.querySelectorAll('#list-tbody tr').forEach(tr => {\n listMap[tr.dataset.name] = { source: tr.dataset.source, product: tr.dataset.product };\n});\ndocument.querySelectorAll('.proj-card').forEach(card => {\n const sub = card.querySelector('.card-sub')?.innerText || '';\n const product = sub.split('·')[0].trim();\n const time = card.querySelector('.card-time')?.innerText || '';\n const name = card.dataset.name;\n const listed = listMap[name] || {};\n card.dataset.product = listed.product || product;\n card.dataset.category = inferCategory(card.dataset.product);\n card.dataset.source = listed.source || '';\n card.dataset.time = parseTimeBucket(time);\n});\n\n// 计算唯一值列表用于填充下拉\nfunction uniqueValues(key) {\n const set = new Set();\n document.querySelectorAll('#list-tbody tr').forEach(tr => {\n const v = tr.dataset[key];\n if (v) set.add(v);\n });\n return [...set];\n}\n\n// 填充菜单\nfunction buildMenu(key, options) {\n const wrap = document.querySelector(`.chip-wrap[data-key=\"${key}\"]`);\n const menu = wrap.querySelector('.chip-menu');\n const checkSvg = '<svg class=\"mi-check\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\"><polyline points=\"3 8 7 12 13 4\"/></svg>';\n const all = `<div class=\"mi selected\" data-value=\"all\">${checkSvg}<span>全部${CHIP_LABELS[key]}</span></div><div class=\"mi-sep\"></div>`;\n const items = options.map(o => `<div class=\"mi\" data-value=\"${o.value}\">${checkSvg}<span>${o.label}</span></div>`).join('');\n menu.innerHTML = all + items;\n}\n\nbuildMenu('product', CATEGORIES.map(v => ({ value: v, label: v })));\nbuildMenu('source', uniqueValues('source').filter(Boolean).map(v => ({ value: v, label: v })));\nbuildMenu('time', [\n { value: 'today', label: '今天' },\n { value: 'week', label: '本周' },\n { value: 'month', label: '本月' },\n { value: 'earlier', label: '更早' },\n]);\n\nconst TOTAL = document.querySelectorAll('#list-tbody tr').length;\n\nfunction applyFilter() {\n const isList = state.view === 'list';\n document.getElementById('list-view').style.display = isList ? '' : 'none';\n document.getElementById('grid-view').style.display = isList ? 'none' : '';\n\n const items = document.querySelectorAll(isList ? '#list-tbody tr' : '.proj-card');\n let visible = 0;\n items.forEach(el => {\n const status = el.dataset.status || '';\n const name = (el.dataset.name || '').toLowerCase();\n const matchFilter = state.filter === 'all' || status.split(' ').includes(state.filter);\n const matchSearch = !state.search\n || name.includes(state.search.toLowerCase())\n || (el.dataset.product || '').toLowerCase().includes(state.search.toLowerCase());\n const matchProduct = state.product === 'all' || el.dataset.category === state.product;\n const matchSource = state.source === 'all' || el.dataset.source === state.source;\n const matchTime = state.time === 'all' || el.dataset.time === state.time;\n const show = matchFilter && matchSearch && matchProduct && matchSource && matchTime;\n el.style.display = show ? '' : 'none';\n if (show) visible++;\n });\n\n const empty = document.getElementById('empty');\n if (visible === 0) {\n empty.classList.add('show');\n document.getElementById('list-view').style.display = 'none';\n document.getElementById('grid-view').style.display = 'none';\n } else {\n empty.classList.remove('show');\n }\n\n document.getElementById('result-meta').innerHTML = `// 显示 <span class=\"count\">${visible}</span> / ${TOTAL} 个项目`;\n\n // 是否有任意筛选生效 → 显示\"清空筛选\"\n const hasFilter = state.filter !== 'all' || state.search || state.product !== 'all' || state.source !== 'all' || state.time !== 'all';\n document.getElementById('clear-filters').hidden = !hasFilter;\n}\n\n// 清空所有筛选\ndocument.getElementById('clear-filters').addEventListener('click', () => {\n state.filter = 'all'; state.search = ''; state.product = 'all'; state.source = 'all'; state.time = 'all';\n // tab 回到\"全部\"\n document.querySelectorAll('#status-tabs .tab').forEach(t => t.classList.toggle('active', t.dataset.filter === 'all'));\n // 搜索框清空\n document.getElementById('search-input').value = '';\n // 三个 chip 同步\n ['product', 'source', 'time'].forEach(syncChipUI);\n applyFilter();\n Shell.toast('已清空筛选');\n});\n\n// chip label + active 状态同步\nfunction syncChipUI(key) {\n const wrap = document.querySelector(`.chip-wrap[data-key=\"${key}\"]`);\n const label = wrap.querySelector('.chip-label');\n const chip = wrap.querySelector('.chip');\n const v = state[key];\n if (v === 'all') {\n label.textContent = CHIP_LABELS[key];\n chip.classList.remove('active');\n } else {\n label.textContent = key === 'time' ? TIME_LABEL[v] : v;\n chip.classList.add('active');\n }\n wrap.querySelectorAll('.mi').forEach(mi => mi.classList.toggle('selected', mi.dataset.value === v));\n}\n\n// Chip wrap → 点击 chip 打开/关闭,点击菜单项设置 state\ndocument.querySelectorAll('.chip-wrap').forEach(wrap => {\n const key = wrap.dataset.key;\n wrap.querySelector('.chip').addEventListener('click', e => {\n e.stopPropagation();\n const isOpen = wrap.classList.contains('open');\n document.querySelectorAll('.chip-wrap.open').forEach(w => w.classList.remove('open'));\n if (!isOpen) wrap.classList.add('open');\n });\n wrap.querySelectorAll('.mi').forEach(mi => {\n mi.addEventListener('click', e => {\n e.stopPropagation();\n state[key] = mi.dataset.value;\n wrap.classList.remove('open');\n syncChipUI(key);\n applyFilter();\n });\n });\n});\n\n// 点击页面其他地方关闭打开的菜单\ndocument.addEventListener('click', () => {\n document.querySelectorAll('.chip-wrap.open').forEach(w => w.classList.remove('open'));\n});\n\n// Tab clicks\ndocument.querySelectorAll('#status-tabs .tab').forEach(t => {\n t.addEventListener('click', () => {\n document.querySelectorAll('#status-tabs .tab').forEach(x => x.classList.remove('active'));\n t.classList.add('active');\n state.filter = t.dataset.filter;\n applyFilter();\n });\n});\n\n// View toggle\ndocument.querySelectorAll('.view-toggle button').forEach(b => {\n b.addEventListener('click', () => {\n document.querySelectorAll('.view-toggle button').forEach(x => x.classList.remove('active'));\n b.classList.add('active');\n state.view = b.dataset.view;\n applyFilter();\n });\n});\n\n// Search\ndocument.getElementById('search-input').addEventListener('input', e => {\n state.search = e.target.value.trim();\n applyFilter();\n});\n\n// 从 URL ?filter=wip|done|fail|all 接收外部跳转 (例如工作台 stat 卡)\n(function applyUrlFilter() {\n try {\n const q = new URLSearchParams(location.search);\n const f = q.get('filter');\n if (!f) return;\n const valid = ['all', 'wip', 'done', 'fail'];\n if (!valid.includes(f)) return;\n state.filter = f;\n document.querySelectorAll('#status-tabs .tab').forEach(t =>\n t.classList.toggle('active', t.dataset.filter === f)\n );\n } catch (e) {}\n})();\n\napplyFilter();\n\n// ============================================================\n// 列表行 + 网格卡 删除按钮 + 管理项目 模式\n// ============================================================\nconst PROJECT_NEVER_DELETE = []; // 未来可挂载\"未完成不可删\"逻辑\nfunction getProjectRefs(_card) { return []; } // 项目无引用检查 (PRD 没说项目反向)\n\n// 给所有 list 行末 td 注入 row-more (含删除气泡)\nconst moreHTML = '<span class=\"row-more\" onclick=\"event.stopPropagation()\"><svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\"><circle cx=\"3\" cy=\"8\" r=\"1.2\" fill=\"currentColor\"/><circle cx=\"8\" cy=\"8\" r=\"1.2\" fill=\"currentColor\"/><circle cx=\"13\" cy=\"8\" r=\"1.2\" fill=\"currentColor\"/></svg><div class=\"row-more-tip\"><button class=\"mi mi-del-row\" type=\"button\" onclick=\"event.stopPropagation();\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2\"/><path d=\"M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6\"/></svg>删除项目</button></div></span>';\ndocument.querySelectorAll('#list-tbody tr').forEach(tr => {\n const lastTd = tr.querySelector('td:last-child');\n if (!lastTd) return;\n if (!lastTd.querySelector('.row-more')) {\n // 已经有 row-action 的合并, 否则直接放\n if (lastTd.querySelector('.row-action')) {\n lastTd.querySelector('.row-action').insertAdjacentHTML('beforeend', moreHTML);\n } else {\n lastTd.innerHTML = lastTd.innerHTML + moreHTML;\n }\n }\n});\n\n// 删除确认 modal\nconst delBg = document.getElementById('del-confirm-bg');\nconst delBody = document.getElementById('del-confirm-body');\nconst delCancel = document.getElementById('del-confirm-cancel');\nconst delOk = document.getElementById('del-confirm-ok');\nlet _delQueue = [];\nfunction openDelConfirm(targets) {\n _delQueue = targets;\n if (targets.length === 1) {\n const name = targets[0].dataset.name || '该项目';\n delBody.innerHTML = '即将删除项目 <span class=\"mono-acc\">' + name + '</span>,已生成的视频和中间素材将清理,被引用的共享资产不受影响。';\n } else {\n delBody.innerHTML = '即将删除 <span class=\"mono-acc\">' + targets.length + ' 个项目</span>,这些项目的视频和中间素材都将清理。';\n }\n delBg.classList.add('show');\n}\nfunction closeDelConfirm() { delBg.classList.remove('show'); _delQueue = []; }\ndelCancel.addEventListener('click', closeDelConfirm);\ndelBg.addEventListener('click', e => { if (e.target === delBg) closeDelConfirm(); });\ndelOk.addEventListener('click', () => {\n const n = _delQueue.length;\n\n // 同步 localStorage (注入的项目带 data-extra-id) + 把同名的另一视图元素一起删\n const KEY = 'fs-extra-projects';\n let extraList = [];\n try { extraList = JSON.parse(localStorage.getItem(KEY) || '[]'); } catch (e) {}\n let extraDirty = false;\n const toRemove = new Set();\n\n _delQueue.forEach(el => {\n toRemove.add(el);\n // 配对: 同 data-name 的另一视图元素一起删\n const name = el.dataset.name;\n if (name) {\n document.querySelectorAll(`[data-name=\"${CSS.escape(name)}\"]`).forEach(other => {\n if (other.matches('.proj-card, #list-tbody tr')) toRemove.add(other);\n });\n }\n // 注入的项目 → 从 localStorage 移除\n const eid = el.dataset.extraId;\n if (eid) {\n const idx = extraList.findIndex(p => p.id === eid);\n if (idx >= 0) { extraList.splice(idx, 1); extraDirty = true; }\n }\n });\n\n toRemove.forEach(el => el.remove());\n if (extraDirty) {\n try { localStorage.setItem(KEY, JSON.stringify(extraList)); } catch (e) {}\n }\n\n closeDelConfirm();\n Shell.toast('已删除', n === 1 ? '项目已移除' : '已删除 ' + n + ' 个项目');\n updateBulkBar();\n});\n\n// 网格卡 card-del-btn 绑定\ndocument.querySelectorAll('.proj-card .card-del-btn').forEach(btn => {\n btn.addEventListener('click', e => {\n e.stopPropagation();\n const card = btn.closest('.proj-card');\n if (card) openDelConfirm([card]);\n });\n});\n// 列表行 row-more 删除项目按钮绑定\ndocument.querySelectorAll('.mi-del-row').forEach(btn => {\n btn.addEventListener('click', e => {\n e.stopPropagation();\n const tr = btn.closest('tr');\n if (tr) openDelConfirm([tr]);\n });\n});\n\n// 管理项目模式\nconst projManageBtn = document.getElementById('proj-manage-btn');\nconst projManageLabel = projManageBtn.querySelector('.proj-manage-label');\nconst bulkBar = document.getElementById('bulk-bar');\nconst bulkCount = document.getElementById('bulk-count');\nconst bulkClear = document.getElementById('bulk-clear');\nconst bulkDel = document.getElementById('bulk-del');\nconst bulkExit = document.getElementById('bulk-exit');\n\nfunction getSelectedProjects() {\n return [\n ...document.querySelectorAll('.proj-card.selected'),\n ...document.querySelectorAll('#list-tbody tr.selected'),\n ];\n}\nfunction updateBulkBar() {\n const sel = getSelectedProjects();\n bulkCount.textContent = sel.length;\n bulkDel.disabled = sel.length === 0;\n bulkDel.style.opacity = sel.length === 0 ? '.4' : '1';\n}\nfunction enterEditMode() {\n document.body.classList.add('edit-mode');\n projManageBtn.classList.add('active');\n projManageLabel.textContent = '完成';\n updateBulkBar();\n}\nfunction exitEditMode() {\n document.body.classList.remove('edit-mode');\n projManageBtn.classList.remove('active');\n projManageLabel.textContent = '管理项目';\n document.querySelectorAll('.proj-card.selected, #list-tbody tr.selected').forEach(c => c.classList.remove('selected'));\n}\nprojManageBtn.addEventListener('click', () => {\n if (document.body.classList.contains('edit-mode')) exitEditMode();\n else enterEditMode();\n});\nbulkExit.addEventListener('click', exitEditMode);\nbulkClear.addEventListener('click', () => {\n document.querySelectorAll('.proj-card.selected, #list-tbody tr.selected').forEach(c => c.classList.remove('selected'));\n updateBulkBar();\n});\nbulkDel.addEventListener('click', () => {\n const sel = getSelectedProjects();\n if (sel.length) openDelConfirm(sel);\n});\n\n// 编辑模式下,卡片/列表行 点击切换 selected\ndocument.querySelectorAll('.proj-card').forEach(card => {\n card.addEventListener('click', e => {\n if (!document.body.classList.contains('edit-mode')) return;\n e.stopImmediatePropagation(); e.preventDefault();\n card.classList.toggle('selected');\n updateBulkBar();\n }, true);\n});\ndocument.querySelectorAll('#list-tbody tr').forEach(tr => {\n tr.addEventListener('click', e => {\n if (!document.body.classList.contains('edit-mode')) return;\n e.stopImmediatePropagation(); e.preventDefault();\n tr.classList.toggle('selected');\n updateBulkBar();\n }, true);\n});\n</script>\n<script src=\"/exact/assets/api-bridge.js?v=2026060114\"></script>\n</body>\n</html>\n",
|
||
"register": "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta name=\"x-airshelf-exact-source\" content=\"register.html\">\n<meta charset=\"utf-8\">\n<title>注册团队 · Airshelf</title>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"/exact/assets/restraint.css?v=2026060101\">\n<style>\n body { margin: 0; min-height: 100vh; background: var(--background-base); display: grid; place-items: center; padding: 32px 24px; }\n\n .auth-wrap { width: 100%; max-width: 980px; display: grid; grid-template-columns: minmax(0, 1fr) 460px; gap: 0; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); position: relative; overflow: hidden; min-height: 620px; }\n\n /* 4 装订线 */\n .auth-wrap::before, .auth-wrap::after,\n .auth-wrap > .corner-tr, .auth-wrap > .corner-bl {\n content: ''; position: absolute; width: 14px; height: 14px;\n background: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 21' fill='%23e8e8e8'%3E%3Cpath d='M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z'/%3E%3C/svg%3E\") no-repeat center;\n background-size: contain; pointer-events: none; z-index: 2;\n }\n .auth-wrap::before { top: -7px; left: -7px; }\n .auth-wrap::after { bottom: -7px; right: -7px; }\n .auth-wrap > .corner-tr { top: -7px; right: -7px; }\n .auth-wrap > .corner-bl { bottom: -7px; left: -7px; }\n\n /* 左:品牌 + 价值点 */\n .auth-brand { background: var(--accent-black); color: var(--accent-white); padding: 40px 44px; display: flex; flex-direction: column; position: relative; overflow: hidden; }\n .auth-brand .logo { display: flex; align-items: center; height: 56px; }\n .auth-brand .logo-img { display: block; width: 196px; height: auto; margin: -14px 0 -10px -14px; object-fit: contain; }\n .auth-brand .tag { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.5); letter-spacing: .08em; text-transform: uppercase; margin-top: 4px; }\n\n .auth-brand .hero { margin-top: 28px; }\n .auth-brand .hero h1 { font-size: 28px; font-weight: 700; letter-spacing: -.02em; line-height: 1.25; margin: 0 0 14px; }\n .auth-brand .hero h1 .h { color: var(--heat); }\n .auth-brand .hero p { font-size: 13.5px; color: rgba(255,255,255,.62); line-height: 1.7; max-width: 320px; margin: 0; }\n\n /* 价值点列表 */\n .val-list { margin: 30px 0 0; display: flex; flex-direction: column; gap: 14px; }\n .val-item { display: grid; grid-template-columns: 24px minmax(0,1fr); gap: 12px; align-items: start; }\n .val-item .ic-v { width: 22px; height: 22px; border: 1px solid rgba(255,255,255,.18); border-radius: var(--r-sm); display: grid; place-items: center; color: var(--heat); }\n .val-item .ic-v svg { width: 12px; height: 12px; }\n .val-item .txt-v { font-size: 12.5px; color: rgba(255,255,255,.78); line-height: 1.5; }\n .val-item .txt-v b { color: var(--accent-white); font-weight: 600; }\n\n .auth-brand .foot { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.32); letter-spacing: .04em; margin-top: auto; padding-top: 24px; display: flex; gap: 14px; }\n .auth-brand .foot a { color: rgba(255,255,255,.5); text-decoration: none; }\n .auth-brand .foot a:hover { color: var(--heat); }\n\n /* 右:表单 */\n .auth-form { padding: 40px 44px 32px; display: flex; flex-direction: column; }\n .auth-form .h-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }\n .auth-form h2 { font-size: 22px; font-weight: 600; letter-spacing: -.012em; margin: 0; }\n .auth-form .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; }\n .auth-form .lead { font-size: 13px; color: var(--black-alpha-56); margin: 0 0 22px; }\n\n .field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }\n .field-label { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--black-alpha-56); font-family: var(--font-mono); letter-spacing: .02em; }\n .field-label .req { color: var(--accent-crimson); }\n .field-label .hint { margin-left: auto; font-size: 10.5px; color: var(--black-alpha-32); }\n .field-input-wrap { position: relative; }\n .field-input-wrap .ic-l { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--black-alpha-32); width: 14px; height: 14px; pointer-events: none; }\n .field input { width: 100%; box-sizing: border-box; padding: 11px 12px 11px 36px; font-size: 13.5px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); font-family: inherit; color: var(--accent-black); transition: border-color var(--t-base), box-shadow var(--t-base); }\n .field input:focus { outline: none; border-color: var(--heat); box-shadow: 0 0 0 3px var(--heat-12); }\n .field input::placeholder { color: var(--black-alpha-32); }\n .field .toggle-pwd { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; color: var(--black-alpha-48); cursor: pointer; background: none; border: 0; padding: 0; display: grid; place-items: center; }\n .field .toggle-pwd:hover { color: var(--accent-black); }\n .field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 14px; }\n .field-row .field { margin-bottom: 0; }\n\n .agree { display: flex; align-items: flex-start; gap: 8px; font-size: 12px; color: var(--black-alpha-56); line-height: 1.6; margin: 8px 0 18px; cursor: pointer; user-select: none; }\n .agree input { margin-top: 3px; width: 13px; height: 13px; accent-color: var(--heat); flex-shrink: 0; }\n .agree a { color: var(--heat); text-decoration: none; }\n .agree a:hover { text-decoration: underline; }\n\n .btn-cta { background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: var(--r-md); padding: 12px 14px; font-size: 14px; font-weight: 600; cursor: pointer; font-family: inherit; transition: box-shadow var(--t-base), transform var(--t-base); display: inline-flex; align-items: center; justify-content: center; gap: 8px; }\n .btn-cta:hover { box-shadow: 0 4px 14px rgba(250,93,25,.28); }\n .btn-cta:active { transform: translateY(1px); }\n .btn-cta:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; }\n .btn-cta svg { width: 15px; height: 15px; }\n\n .switch-row { margin-top: 22px; text-align: center; font-size: 12.5px; color: var(--black-alpha-56); }\n .switch-row a { color: var(--heat); text-decoration: none; font-weight: 500; }\n .switch-row a:hover { text-decoration: underline; }\n\n @media (max-width: 820px) {\n .auth-wrap { grid-template-columns: 1fr; min-height: 0; }\n .auth-brand { padding: 32px 28px; }\n .auth-brand .val-list { display: none; }\n .auth-form { padding: 32px 28px; }\n }\n\n .top-back { position: fixed; top: 20px; left: 24px; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); text-decoration: none; letter-spacing: .04em; }\n .top-back:hover { color: var(--heat); }\n</style>\n</head>\n<body>\n<a class=\"top-back\" href=\"login.html\">← 返回登录</a>\n\n<div class=\"auth-wrap\">\n <span class=\"corner-tr\" aria-hidden></span><span class=\"corner-bl\" aria-hidden></span>\n\n <aside class=\"auth-brand\">\n <div class=\"logo\">\n <img class=\"logo-img\" src=\"/exact/assets/logo-dark.png\" alt=\"Airshelf\">\n </div>\n <div class=\"tag\">// SHORT-VIDEO COMMERCE PLATFORM</div>\n\n <div class=\"hero\">\n <h1>开通团队,<br>开始 <span class=\"h\">AI 带货</span> 第一条短剧</h1>\n <p>个人 / 企业团队 1-2 小时出成片,失败不扣费,确认后扣。</p>\n </div>\n\n <div class=\"val-list\">\n <div class=\"val-item\">\n <span class=\"ic-v\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M20 6L9 17l-5-5\"/></svg></span>\n <span class=\"txt-v\"><b>5 阶段流水线</b> · 脚本 → 资产 → 故事板 → 视频片段 → 拼接导出</span>\n </div>\n <div class=\"val-item\">\n <span class=\"ic-v\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M20 6L9 17l-5-5\"/></svg></span>\n <span class=\"txt-v\"><b>失败不扣费</b> · 任务仅在用户通过时扣费,失败 / 超时 / 重跑一律不扣</span>\n </div>\n <div class=\"val-item\">\n <span class=\"ic-v\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M20 6L9 17l-5-5\"/></svg></span>\n <span class=\"txt-v\"><b>团队 + 角色 + 四层额度</b> · 超管 / 团管 / 成员,日 / 月 + 团队 / 总四层防超支</span>\n </div>\n <div class=\"val-item\">\n <span class=\"ic-v\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M20 6L9 17l-5-5\"/></svg></span>\n <span class=\"txt-v\"><b>跨项目资产库</b> · 主播 / 场景 / 商品图沉淀复用,不重复生成</span>\n </div>\n </div>\n\n <div class=\"foot\">\n <a href=\"#\">关于</a>\n <a href=\"#\">定价</a>\n <a href=\"#\">联系</a>\n <a href=\"#\">隐私</a>\n </div>\n </aside>\n\n <main class=\"auth-form\">\n <div class=\"h-row\">\n <h2>注册团队</h2>\n <span class=\"sub\">// /auth/register</span>\n </div>\n <p class=\"lead\">填写团队信息开通账户,默认成为团队超管。</p>\n\n <form id=\"register-form\" autocomplete=\"off\" onsubmit=\"event.preventDefault(); doRegister();\">\n <div class=\"field\">\n <label class=\"field-label\" for=\"reg-team\">团队名 <span class=\"req\">*</span></label>\n <div class=\"field-input-wrap\">\n <svg class=\"ic-l\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\"/><path d=\"M9 22V12h6v10\"/></svg>\n <input type=\"text\" id=\"reg-team\" placeholder=\"例: 小李的店 / XX 文化传媒\" required>\n </div>\n </div>\n\n <div class=\"field\">\n <label class=\"field-label\" for=\"reg-email\">超管邮箱 <span class=\"req\">*</span><span class=\"hint\">用于成员邀请 + 找回密码</span></label>\n <div class=\"field-input-wrap\">\n <svg class=\"ic-l\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z\"/><path d=\"m22 6-10 7L2 6\"/></svg>\n <input type=\"email\" id=\"reg-email\" placeholder=\"name@company.com\" required>\n </div>\n </div>\n\n <div class=\"field-row\">\n <div class=\"field\">\n <label class=\"field-label\" for=\"reg-pwd\">密码 <span class=\"req\">*</span></label>\n <div class=\"field-input-wrap\">\n <svg class=\"ic-l\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"11\" width=\"18\" height=\"11\" rx=\"2\"/><path d=\"M7 11V7a5 5 0 0 1 10 0v4\"/></svg>\n <input type=\"password\" id=\"reg-pwd\" placeholder=\"至少 8 位\" required>\n <button type=\"button\" class=\"toggle-pwd\" onclick=\"togglePwd('reg-pwd')\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/></svg>\n </button>\n </div>\n </div>\n <div class=\"field\">\n <label class=\"field-label\" for=\"reg-pwd2\">确认密码 <span class=\"req\">*</span></label>\n <div class=\"field-input-wrap\">\n <svg class=\"ic-l\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"11\" width=\"18\" height=\"11\" rx=\"2\"/><path d=\"M7 11V7a5 5 0 0 1 10 0v4\"/></svg>\n <input type=\"password\" id=\"reg-pwd2\" placeholder=\"再输一次\" required>\n <button type=\"button\" class=\"toggle-pwd\" onclick=\"togglePwd('reg-pwd2')\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/></svg>\n </button>\n </div>\n </div>\n </div>\n\n <div class=\"field\">\n <label class=\"field-label\" for=\"reg-invite\">邀请码 <span class=\"hint\">可选 · 团队邀请才需要</span></label>\n <div class=\"field-input-wrap\">\n <svg class=\"ic-l\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z\"/></svg>\n <input type=\"text\" id=\"reg-invite\" placeholder=\"例: TEAM-XXXX-XXXX\">\n </div>\n </div>\n\n <label class=\"agree\">\n <input type=\"checkbox\" id=\"reg-agree\" checked>\n <span>我已阅读并同意 <a href=\"#\" onclick=\"event.preventDefault();_regToast('用户协议','含数据处理 / 内容生成版权 / 计费规则三章 · 完整文本将在正式版接入');\">用户协议</a> 与 <a href=\"#\" onclick=\"event.preventDefault();_regToast('隐私政策','遵循《个人信息保护法》· 团队数据存中国境内 · 默认不用于模型训练');\">隐私政策</a>,知悉「失败不扣费 · 确认后扣」的扣费规则。</span>\n </label>\n\n <button class=\"btn-cta\" type=\"submit\" id=\"reg-submit\">\n 创建团队 · 开始使用\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 12h14M12 5l7 7-7 7\"/></svg>\n </button>\n\n <div class=\"switch-row\">\n 已有账号? <a href=\"login.html\">登录 →</a>\n </div>\n </form>\n </main>\n</div>\n\n<script>\n function togglePwd(id) {\n const inp = document.getElementById(id);\n inp.type = inp.type === 'password' ? 'text' : 'password';\n }\n function doRegister() {\n const team = document.getElementById('reg-team').value.trim();\n const email = document.getElementById('reg-email').value.trim();\n const p1 = document.getElementById('reg-pwd').value;\n const p2 = document.getElementById('reg-pwd2').value;\n const agree = document.getElementById('reg-agree').checked;\n if (!team || !email) return alert('请补全团队名 + 邮箱');\n if (p1.length < 8) return alert('密码至少 8 位');\n if (p1 !== p2) return alert('两次密码不一致');\n if (!agree) return alert('请同意用户协议');\n const btn = document.getElementById('reg-submit');\n btn.innerHTML = '<span style=\"font-family:var(--font-mono);font-size:12px;letter-spacing:.04em;\">// 创建团队中...</span>';\n btn.disabled = true;\n setTimeout(() => { location.href = 'index.html'; }, 900);\n }\n // 轻量 toast(不依赖 shell.js)\n function _regToast(t, sub) {\n let el = document.getElementById('__reg-toast');\n if (!el) {\n el = document.createElement('div');\n el.id = '__reg-toast';\n el.style.cssText = 'position:fixed;left:50%;bottom:36px;transform:translateX(-50%) translateY(20px);background:#fff;border:1px solid #e0e0e0;border-radius:8px;padding:12px 18px;box-shadow:0 8px 24px rgba(0,0,0,.12);display:flex;flex-direction:column;gap:2px;opacity:0;transition:opacity .2s,transform .2s;z-index:9999;font-family:inherit;max-width:380px;';\n document.body.appendChild(el);\n }\n el.innerHTML = '<div style=\"font-size:13.5px;font-weight:600;color:#262626;\">' + t + '</div>' + (sub ? '<div style=\"font-size:11.5px;color:rgba(0,0,0,.56);font-family:\\'Inter\\',system-ui,sans-serif;letter-spacing:.02em;\">// ' + sub + '</div>' : '');\n requestAnimationFrame(() => { el.style.opacity = '1'; el.style.transform = 'translateX(-50%) translateY(0)'; });\n clearTimeout(el._t);\n el._t = setTimeout(() => { el.style.opacity = '0'; el.style.transform = 'translateX(-50%) translateY(20px)'; }, 3000);\n }\n</script>\n<script src=\"/exact/assets/api-bridge.js?v=2026060114\"></script>\n</body>\n</html>\n",
|
||
"settings": "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta name=\"x-airshelf-exact-source\" content=\"settings.html\">\n<meta charset=\"utf-8\">\n<title>设置 · Airshelf</title>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"/exact/assets/restraint.css?v=2026060101\">\n<style>\n /* ─── 设置布局:左 nav + 右 panel ─── */\n .settings-grid { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 24px; align-items: start; }\n\n .settings-nav { position: sticky; top: 16px; }\n .settings-nav .nav-h { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; padding: 0 12px 8px; }\n .settings-nav :where(a, button) { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px 12px; font: inherit; font-size: 13px; color: var(--accent-black); border-radius: var(--r-md); border: 1px solid transparent; background: transparent; cursor: pointer; text-decoration: none; text-align: left; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); position: relative; }\n .settings-nav :where(a, button):hover { background: var(--background-lighter); }\n .settings-nav :where(a, button):focus-visible { outline: 2px solid var(--heat); outline-offset: 2px; }\n .settings-nav a.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); font-weight: 600; }\n .settings-nav :where(a, button) svg { width: 16px; height: 16px; stroke-width: 1.5; flex: 0 0 auto; }\n .settings-nav a .nav-badge { margin-left: auto; font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-48); padding: 1px 6px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-pill); letter-spacing: .02em; line-height: 14px; }\n .settings-nav a.active .nav-badge { color: var(--heat); background: var(--accent-white); border-color: var(--heat-20); }\n .settings-nav a .nav-dot { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); width: 6px; height: 6px; border-radius: 50%; background: var(--heat); display: none; }\n .settings-nav a.has-changes .nav-dot { display: block; }\n .settings-nav a.active .nav-dot { right: -4px; }\n .settings-nav .logout-pill {\n width: calc(100% - 24px);\n height: 38px;\n margin: 4px 12px 0;\n justify-content: center;\n border-radius: var(--r-pill);\n background: var(--accent-black);\n border-color: var(--accent-black);\n color: var(--accent-white);\n font-weight: 500;\n }\n .settings-nav .logout-pill:hover,\n .settings-nav .logout-pill:focus-visible {\n background: var(--black-alpha-88);\n border-color: var(--black-alpha-88);\n color: var(--accent-white);\n }\n\n /* ─── pane ─── */\n .pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 24px; margin-bottom: 16px; }\n .pane h3 { font-size: 14px; font-weight: 600; margin-bottom: 4px; }\n .pane .pane-desc { font-size: 12px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; margin-bottom: 18px; }\n\n /* ─── form row ─── */\n .form-row { display: grid; grid-template-columns: 160px minmax(0, 1fr); gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--border-faint); align-items: center; }\n .form-row:last-child { border-bottom: 0; }\n .form-row .lbl { font-size: 12.5px; color: var(--black-alpha-56); }\n .form-row .lbl .req { color: var(--accent-crimson); margin-left: 2px; }\n .form-row .lbl-sub { font-size: 11px; color: var(--black-alpha-48); font-family: var(--font-mono); margin-top: 2px; letter-spacing: .02em; }\n .form-row .val { display: flex; align-items: center; gap: 10px; min-width: 0; }\n .form-row .val .input, .form-row .val .select { width: 100%; max-width: 380px; }\n .form-row .val .static { font-size: 13px; color: var(--accent-black); font-variant-numeric: tabular-nums; }\n .form-row .val .static.mono { font-family: var(--font-mono); font-size: 12.5px; color: var(--black-alpha-56); }\n .form-row .val .role-tag { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: var(--r-pill); font-size: 11px; font-weight: 500; background: var(--heat-12); color: var(--heat); }\n .form-row .val .role-tag .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--heat); }\n\n /* ─── 头像上传 ─── */\n .avatar-edit { display: flex; align-items: center; gap: 16px; }\n .avatar-edit .av-big { width: 64px; height: 64px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: grid; place-items: center; font-size: 24px; font-weight: 600; color: var(--accent-black); }\n .avatar-edit .av-actions { display: flex; gap: 8px; }\n\n /* ─── toggle switch ─── */\n .switch { position: relative; width: 36px; height: 20px; flex: 0 0 36px; }\n .switch input { opacity: 0; width: 0; height: 0; }\n .switch .slider { position: absolute; inset: 0; background: var(--black-alpha-24); border-radius: 20px; cursor: pointer; transition: background var(--t-base); }\n .switch .slider::before { content: ''; position: absolute; left: 2px; top: 2px; width: 16px; height: 16px; background: var(--accent-white); border-radius: 50%; transition: transform var(--t-base); }\n .switch input:checked + .slider { background: var(--heat); }\n .switch input:checked + .slider::before { transform: translateX(16px); }\n\n /* ─── 偏好选项卡 ─── */\n .pref-choices { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 8px; max-width: 540px; }\n .pref-choice { padding: 10px 12px; border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: all var(--t-base); }\n .pref-choice:hover { background: var(--background-lighter); }\n .pref-choice.selected { border-color: var(--heat); background: var(--heat-12); }\n .pref-choice .t { font-size: 12.5px; font-weight: 600; color: var(--accent-black); }\n .pref-choice .d { font-size: 11px; color: var(--black-alpha-48); margin-top: 2px; font-family: var(--font-mono); letter-spacing: .02em; }\n .pref-choice.selected .t { color: var(--heat); }\n\n /* ─── 时长档 ─── */\n .duration-row { display: flex; gap: 8px; }\n .dur-chip { padding: 6px 14px; border: 1px solid var(--border-faint); border-radius: var(--r-md); font-size: 13px; cursor: pointer; font-family: var(--font-mono); font-variant-numeric: tabular-nums; transition: all var(--t-base); background: var(--surface); }\n .dur-chip:hover { background: var(--background-lighter); }\n .dur-chip.selected { border-color: var(--heat); background: var(--heat-12); color: var(--heat); font-weight: 600; }\n\n /* ─── 设备列表 ─── */\n .device-row { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border-faint); }\n .device-row:last-child { border-bottom: 0; }\n .device-row .ic { width: 36px; height: 36px; border-radius: var(--r-md); background: var(--background-lighter); display: grid; place-items: center; color: var(--black-alpha-56); }\n .device-row .ic svg { width: 18px; height: 18px; }\n .device-row .nm { font-size: 13px; font-weight: 500; }\n .device-row .meta { font-size: 11.5px; color: var(--black-alpha-48); font-family: var(--font-mono); margin-top: 2px; letter-spacing: .02em; }\n .device-row .tag-cur { font-family: var(--font-mono); font-size: 10.5px; padding: 1px 6px; background: var(--accent-forest); color: var(--accent-white); border-radius: var(--r-sm); margin-left: 8px; letter-spacing: .04em; font-weight: 600; }\n .device-row .spacer { margin-left: auto; }\n\n /* ─── 头像上传 modal · V2.1 Restraint ─── */\n .av-up-modal { width: min(440px, 92vw); max-width: min(440px, 92vw); }\n\n /* 预览区:左圆头像 + 右 mono 元数据 · 装订线点划线分隔 */\n .av-up-preview-row { display: flex; align-items: center; gap: 14px; padding-bottom: 14px; margin-bottom: 14px; position: relative; }\n .av-up-preview-row::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: repeating-linear-gradient(to right, var(--border-faint) 0, var(--border-faint) 4px, transparent 4px, transparent 8px); }\n .av-up-preview { width: 64px; height: 64px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: grid; place-items: center; font-size: 22px; font-weight: 600; color: var(--accent-black); overflow: hidden; flex: 0 0 64px; }\n .av-up-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }\n .av-up-preview-meta { min-width: 0; }\n .av-up-preview-meta .t { font-size: 12.5px; font-weight: 600; color: var(--accent-black); margin-bottom: 3px; letter-spacing: .01em; }\n .av-up-preview-meta .d { font-size: 11px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; line-height: 1.55; }\n\n /* 规则文本 · 纯 mono, 装订线分隔 */\n .av-up-rules { margin-top: 12px; padding-top: 10px; border-top: 1px dashed var(--border-faint); font-size: 11px; color: var(--black-alpha-56); font-family: var(--font-mono); letter-spacing: .02em; line-height: 1.7; }\n .av-up-rules .li { display: flex; gap: 8px; }\n .av-up-rules .li::before { content: '//'; color: var(--black-alpha-32); flex: 0 0 auto; }\n .logout-confirm-modal { width: min(440px, 92vw); max-width: min(440px, 92vw); }\n .logout-confirm-copy { margin: 0 0 12px; color: var(--black-alpha-72); }\n .logout-confirm-points {\n display: grid;\n gap: 8px;\n padding: 12px;\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n }\n .logout-confirm-points .li {\n display: flex;\n gap: 8px;\n font-size: 12.5px;\n line-height: 1.55;\n color: var(--black-alpha-64);\n }\n .logout-confirm-points .li::before {\n content: '//';\n flex: 0 0 auto;\n font-family: var(--font-mono);\n color: var(--black-alpha-32);\n }\n .logout-unsaved-note {\n margin-top: 12px;\n padding: 9px 11px;\n border: 1px solid var(--heat-20);\n border-radius: var(--r-md);\n background: var(--heat-12);\n color: var(--heat);\n font-size: 12.5px;\n line-height: 1.6;\n }\n</style>\n</head>\n<body>\n<div id=\"page\">\n\n<div class=\"page-head\">\n <div>\n <h1>设置</h1>\n <div class=\"sub\"><span class=\"mono\">// 个人信息 · 偏好 · 通知 · 安全</span></div>\n </div>\n <div class=\"actions\">\n <button class=\"btn\" id=\"save-cancel\" disabled style=\"opacity:.5; cursor:not-allowed;\">取消</button>\n <button class=\"btn btn-primary\" id=\"save-btn\" disabled style=\"opacity:.5; cursor:not-allowed;\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 12l5 5L20 6\"/></svg>\n 保存所有变更\n </button>\n </div>\n</div>\n\n<div class=\"settings-grid\">\n <!-- 左侧 nav -->\n <aside class=\"settings-nav\" role=\"tablist\" aria-label=\"设置分区\">\n <div class=\"nav-h\">个人</div>\n <a href=\"#sec-profile\" class=\"active\" data-jump=\"sec-profile\" role=\"tab\" aria-controls=\"sec-profile\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"7.5\" r=\"3.5\"/><path d=\"M5 21a7 7 0 0 1 14 0\"/></svg>\n <span>个人信息</span>\n <span class=\"nav-dot\" aria-hidden=\"true\"></span>\n </a>\n <a href=\"#sec-security\" data-jump=\"sec-security\" role=\"tab\" aria-controls=\"sec-security\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z\"/><path d=\"m9 12 2 2 4-4\"/></svg>\n <span>安全</span>\n <span class=\"nav-badge\" data-count-source=\"sec-security\">3</span>\n <span class=\"nav-dot\" aria-hidden=\"true\"></span>\n </a>\n <a href=\"#sec-notify\" data-jump=\"sec-notify\" role=\"tab\" aria-controls=\"sec-notify\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9\"/><path d=\"M10 21a2 2 0 0 0 4 0\"/></svg>\n <span>通知</span>\n <span class=\"nav-badge\" data-count-source=\"sec-notify\">5</span>\n <span class=\"nav-dot\" aria-hidden=\"true\"></span>\n </a>\n <div class=\"nav-h\" style=\"margin-top: 16px;\">偏好</div>\n <a href=\"#sec-pref\" data-jump=\"sec-pref\" role=\"tab\" aria-controls=\"sec-pref\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 4h-7\"/><path d=\"M10 4H3\"/><path d=\"M21 12h-9\"/><path d=\"M8 12H3\"/><path d=\"M21 20h-5\"/><path d=\"M12 20H3\"/><path d=\"M14 2v4\"/><path d=\"M8 10v4\"/><path d=\"M16 18v4\"/></svg>\n <span>创作默认</span>\n <span class=\"nav-dot\" aria-hidden=\"true\"></span>\n </a>\n <a href=\"#sec-display\" data-jump=\"sec-display\" role=\"tab\" aria-controls=\"sec-display\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"3\" y=\"4\" width=\"18\" height=\"13\" rx=\"2\"/><path d=\"M8 21h8\"/><path d=\"M12 17v4\"/></svg>\n <span>显示</span>\n <span class=\"nav-dot\" aria-hidden=\"true\"></span>\n </a>\n <div class=\"nav-h\" style=\"margin-top: 16px;\">账号</div>\n <button class=\"logout-pill\" type=\"button\" id=\"settings-logout-btn\" onclick=\"logoutCurrentDevice()\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\"/><path d=\"m16 17 5-5-5-5\"/><path d=\"M21 12H9\"/></svg>\n <span>退出登录</span>\n </button>\n </aside>\n\n <!-- 右侧内容 -->\n <main>\n <!-- ─── 个人信息 ─── -->\n <section class=\"pane\" id=\"sec-profile\">\n <h3>个人信息</h3>\n <div class=\"pane-desc\">// 头像、姓名、联系方式 · 邮箱用于接收通知</div>\n\n <div class=\"form-row\">\n <div class=\"lbl\">头像</div>\n <div class=\"val\">\n <div class=\"avatar-edit\">\n <div class=\"av-big\" id=\"prof-avatar-preview\">李</div>\n <div class=\"av-actions\">\n <button class=\"btn btn-sm\" type=\"button\" onclick=\"Shell.openModal('avatar-up-bg')\">上传新头像</button>\n <button class=\"btn btn-ghost btn-sm\" type=\"button\" id=\"prof-avatar-reset\">恢复默认</button>\n </div>\n </div>\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"lbl\">显示名称<span class=\"req\">*</span></div>\n <div class=\"val\"><input class=\"input\" id=\"prof-name\" value=\"小李\" data-track></div>\n </div>\n <div class=\"form-row\">\n <div class=\"lbl\">登录邮箱</div>\n <div class=\"val\">\n <input class=\"input\" id=\"prof-email\" value=\"li@shop.com\" data-track>\n <button class=\"btn btn-ghost btn-sm\" onclick=\"Shell.toast('已发送验证邮件', 'li@shop.com')\">验证</button>\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"lbl\">手机号</div>\n <div class=\"val\">\n <input class=\"input\" id=\"prof-phone\" value=\"138****8000\" data-track>\n <button class=\"btn btn-ghost btn-sm\" onclick=\"Shell.toast('已发送短信验证码', '+86 138****8000')\">更换</button>\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"lbl\">所属团队<div class=\"lbl-sub\">// 一人一团队</div></div>\n <div class=\"val\">\n <span class=\"static\">小李的店</span>\n <span class=\"role-tag\"><span class=\"dot\"></span>超管 · 创建者</span>\n <a href=\"team.html\" style=\"font-size: 12px; color: var(--heat); text-decoration: none; margin-left: auto;\">管理团队 →</a>\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"lbl\">用户 ID<div class=\"lbl-sub\">// 不可改</div></div>\n <div class=\"val\"><span class=\"static mono\">USR-2026-A8F2-001</span></div>\n </div>\n </section>\n\n <!-- ─── 安全 ─── -->\n <section class=\"pane\" id=\"sec-security\">\n <h3>安全</h3>\n <div class=\"pane-desc\">// 登录密码、双因素、在用设备</div>\n\n <div class=\"form-row\">\n <div class=\"lbl\">登录密码</div>\n <div class=\"val\">\n <span class=\"static mono\">●●●●●●●●●●</span>\n <span class=\"muted-2 mono\" style=\"font-size: 11px; margin-left: auto;\">上次修改 2026-04-12</span>\n <button class=\"btn btn-sm\" onclick=\"Shell.toast('修改密码', '/settings/password')\" style=\"margin-left: 10px;\">修改</button>\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"lbl\">两步验证<div class=\"lbl-sub\">// 推荐开启</div></div>\n <div class=\"val\">\n <label class=\"switch\"><input type=\"checkbox\" id=\"opt-2fa\"><span class=\"slider\"></span></label>\n <span class=\"static mono\" style=\"font-size: 11.5px; color: var(--black-alpha-48);\">短信 + Authenticator</span>\n </div>\n </div>\n\n <h3 style=\"margin-top: 24px;\">在用设备</h3>\n <div class=\"pane-desc\">// 不在此列表上的设备登录会触发短信告警</div>\n <div>\n <div class=\"device-row\">\n <div class=\"ic\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"4\" width=\"18\" height=\"14\" rx=\"2\"/><path d=\"M2 20h20\"/></svg></div>\n <div>\n <div class=\"nm\">MacBook Pro · Chrome<span class=\"tag-cur\">CURRENT</span></div>\n <div class=\"meta\">// 上海 · 2026-05-21 14:08 · IP 116.xxx.xxx.42</div>\n </div>\n <div class=\"spacer\"></div>\n <span class=\"muted-2 mono\" style=\"font-size: 11px;\">当前会话</span>\n </div>\n <div class=\"device-row\">\n <div class=\"ic\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"6\" y=\"2\" width=\"12\" height=\"20\" rx=\"2\"/><path d=\"M11 18h2\"/></svg></div>\n <div>\n <div class=\"nm\">iPhone 15 · Safari</div>\n <div class=\"meta\">// 上海 · 2026-05-20 21:43</div>\n </div>\n <div class=\"spacer\"></div>\n <button class=\"btn btn-ghost btn-sm\" onclick=\"Shell.toast('已下线', 'iPhone 15')\">下线</button>\n </div>\n <div class=\"device-row\">\n <div class=\"ic\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"4\" width=\"18\" height=\"14\" rx=\"2\"/><path d=\"M2 20h20\"/></svg></div>\n <div>\n <div class=\"nm\">Windows · Edge</div>\n <div class=\"meta\">// 杭州 · 2026-05-18 09:12</div>\n </div>\n <div class=\"spacer\"></div>\n <button class=\"btn btn-ghost btn-sm\" onclick=\"Shell.toast('已下线', 'Windows Edge')\">下线</button>\n </div>\n </div>\n <div style=\"margin-top: 14px;\">\n <button class=\"btn\" onclick=\"if(confirm('下线所有其他设备?')) Shell.toast('已下线其他设备', '2 个')\">下线所有其他设备</button>\n </div>\n </section>\n\n <!-- ─── 通知 ─── -->\n <section class=\"pane\" id=\"sec-notify\">\n <h3>通知</h3>\n <div class=\"pane-desc\">// 邮件、短信、站内提示开关</div>\n\n <div class=\"form-row\">\n <div class=\"lbl\">项目完成通知<div class=\"lbl-sub\">// 视频导出后</div></div>\n <div class=\"val\">\n <label class=\"switch\"><input type=\"checkbox\" id=\"n-export\" checked><span class=\"slider\"></span></label>\n <span class=\"static mono\" style=\"font-size: 11.5px; color: var(--black-alpha-48);\">站内 · 邮件 · 短信</span>\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"lbl\">任务失败告警</div>\n <div class=\"val\">\n <label class=\"switch\"><input type=\"checkbox\" id=\"n-fail\" checked><span class=\"slider\"></span></label>\n <span class=\"static mono\" style=\"font-size: 11.5px; color: var(--black-alpha-48);\">站内 · 邮件</span>\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"lbl\">额度不足提醒<div class=\"lbl-sub\">// 团队或个人剩余 < 20%</div></div>\n <div class=\"val\">\n <label class=\"switch\"><input type=\"checkbox\" id=\"n-quota\" checked><span class=\"slider\"></span></label>\n <span class=\"static mono\" style=\"font-size: 11.5px; color: var(--black-alpha-48);\">站内 · 短信</span>\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"lbl\">异地登录告警</div>\n <div class=\"val\">\n <label class=\"switch\"><input type=\"checkbox\" id=\"n-login\" checked><span class=\"slider\"></span></label>\n <span class=\"static mono\" style=\"font-size: 11.5px; color: var(--black-alpha-48);\">短信</span>\n </div>\n </div>\n </section>\n\n <!-- ─── 创作默认 ─── -->\n <section class=\"pane\" id=\"sec-pref\">\n <h3>创作默认</h3>\n <div class=\"pane-desc\">// 新建项目时的预填值,可在向导中改</div>\n\n <div class=\"form-row\" style=\"grid-template-columns: 160px 1fr; align-items: flex-start;\">\n <div class=\"lbl\" style=\"padding-top: 4px;\">默认模板</div>\n <div class=\"val\" style=\"display: block;\">\n <div class=\"pref-choices\" id=\"pref-template\">\n <div class=\"pref-choice selected\" data-v=\"pain\"><div class=\"t\">痛点种草</div><div class=\"d\">// 30s 默认档</div></div>\n <div class=\"pref-choice\" data-v=\"unbox\"><div class=\"t\">开箱测评</div><div class=\"d\">// 45s 默认档</div></div>\n <div class=\"pref-choice\" data-v=\"compare\"><div class=\"t\">对比展示</div><div class=\"d\">// 45s 默认档</div></div>\n <div class=\"pref-choice\" data-v=\"howto\"><div class=\"t\">教程演示</div><div class=\"d\">// 60s 默认档</div></div>\n <div class=\"pref-choice\" data-v=\"drama\"><div class=\"t\">剧情带货</div><div class=\"d\">// 60s 默认档</div></div>\n </div>\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"lbl\">默认时长档</div>\n <div class=\"val\">\n <div class=\"duration-row\" id=\"pref-duration\">\n <span class=\"dur-chip\" data-v=\"30\">30s</span>\n <span class=\"dur-chip\" data-v=\"45\">45s</span>\n <span class=\"dur-chip selected\" data-v=\"60\">60s</span>\n </div>\n <span class=\"static mono\" style=\"font-size: 11.5px; color: var(--black-alpha-48); margin-left: 10px;\">// 60s = 4 段 × 15s</span>\n </div>\n </div>\n <div class=\"form-row\" style=\"grid-template-columns: 160px 1fr; align-items: flex-start;\">\n <div class=\"lbl\" style=\"padding-top: 4px;\">默认字幕样式</div>\n <div class=\"val\" style=\"display: block;\">\n <div class=\"pref-choices\" id=\"pref-subtitle\">\n <div class=\"pref-choice selected\" data-v=\"big-variety\"><div class=\"t\">大字综艺</div><div class=\"d\">// 抖音热门</div></div>\n <div class=\"pref-choice\" data-v=\"clean-ec\"><div class=\"t\">简洁电商</div><div class=\"d\">// 信息清晰</div></div>\n <div class=\"pref-choice\" data-v=\"premium\"><div class=\"t\">高级排版</div><div class=\"d\">// 居中衬线</div></div>\n <div class=\"pref-choice\" data-v=\"bullet\"><div class=\"t\">弹幕轻量</div><div class=\"d\">// 滚动出现</div></div>\n <div class=\"pref-choice\" data-v=\"emphasis\"><div class=\"t\">强调爆款</div><div class=\"d\">// 高对比</div></div>\n </div>\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"lbl\">默认 BGM 库</div>\n <div class=\"val\">\n <select class=\"select\" id=\"pref-bgm\" data-track>\n <option value=\"kapian\">抖音 Top10 卡点曲库</option>\n <option value=\"emotion\">情绪向 · 治愈/悬念</option>\n <option value=\"urban\">都市电子 · 通勤场景</option>\n <option value=\"none\">无 BGM</option>\n </select>\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"lbl\">默认转场</div>\n <div class=\"val\">\n <select class=\"select\" id=\"pref-transition\" data-track>\n <option>无转场</option>\n <option selected>淡入淡出 · 0.3s</option>\n <option>滑动 · 0.3s</option>\n <option>缩放 · 0.3s</option>\n </select>\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"lbl\">导出水印<div class=\"lbl-sub\">// VIP 可关闭</div></div>\n <div class=\"val\">\n <label class=\"switch\"><input type=\"checkbox\" id=\"opt-watermark\" checked disabled><span class=\"slider\"></span></label>\n <span class=\"static mono\" style=\"font-size: 11.5px; color: var(--black-alpha-48);\">右下角 · Airshelf</span>\n <a href=\"account.html\" style=\"font-size: 12px; color: var(--heat); text-decoration: none; margin-left: auto;\">升级 VIP →</a>\n </div>\n </div>\n </section>\n\n <!-- ─── 显示 ─── -->\n <section class=\"pane\" id=\"sec-display\">\n <h3>显示</h3>\n <div class=\"pane-desc\">// 界面外观与语言</div>\n\n <div class=\"form-row\">\n <div class=\"lbl\">外观</div>\n <div class=\"val\">\n <select class=\"select\" id=\"pref-theme\" data-track>\n <option selected>跟随系统</option>\n <option>浅色</option>\n <option disabled>深色(V2)</option>\n </select>\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"lbl\">语言</div>\n <div class=\"val\">\n <select class=\"select\" id=\"pref-lang\" data-track>\n <option selected>简体中文</option>\n <option disabled>English(V2)</option>\n </select>\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"lbl\">表格密度</div>\n <div class=\"val\">\n <select class=\"select\" id=\"pref-density\" data-track>\n <option>紧凑</option>\n <option selected>标准</option>\n <option>宽松</option>\n </select>\n </div>\n </div>\n </section>\n\n <div style=\"text-align: center; padding: 24px 0 8px; color: var(--black-alpha-32); font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em;\">\n // Airshelf · v2.1 · build 20260521\n </div>\n </main>\n</div>\n\n<!-- ─── 退出登录确认 modal ─── -->\n<div class=\"modal-bg\" id=\"logout-confirm-bg\" onclick=\"if(event.target===this)Shell.closeModal('logout-confirm-bg')\">\n <div class=\"modal logout-confirm-modal\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"logout-confirm-title\" aria-describedby=\"logout-confirm-desc\">\n <span class=\"corner-tr\" aria-hidden></span><span class=\"corner-bl\" aria-hidden></span>\n <div class=\"modal-h\">\n <div class=\"ic-m\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\"/><path d=\"m16 17 5-5-5-5\"/><path d=\"M21 12H9\"/></svg>\n </div>\n <div class=\"ti\" id=\"logout-confirm-title\">退出当前账号<span>// LOG OUT CURRENT SESSION</span></div>\n </div>\n <div class=\"modal-b\" id=\"logout-confirm-desc\">\n <p class=\"logout-confirm-copy\">确认后将退出当前设备上的 Airshelf,再次使用需要重新登录。</p>\n <div class=\"logout-confirm-points\">\n <div class=\"li\">项目、资产、团队成员与余额数据都会保留</div>\n <div class=\"li\">仅影响当前浏览器会话,不会下线其他设备</div>\n </div>\n <div class=\"logout-unsaved-note\" id=\"logout-unsaved-note\" hidden>当前有未保存的设置变更,退出后这些变更不会保存。</div>\n </div>\n <div class=\"modal-f\">\n <button class=\"btn\" type=\"button\" id=\"logout-confirm-cancel\" onclick=\"Shell.closeModal('logout-confirm-bg')\">取消</button>\n <button class=\"btn btn-primary\" type=\"button\" id=\"logout-confirm-ok\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\"/><path d=\"m16 17 5-5-5-5\"/><path d=\"M21 12H9\"/></svg>\n 确认退出\n </button>\n </div>\n </div>\n</div>\n\n<!-- ─── 上传头像 modal ─── -->\n<div class=\"modal-bg\" id=\"avatar-up-bg\" onclick=\"if(event.target===this)Shell.closeModal('avatar-up-bg')\">\n <div class=\"modal av-up-modal\">\n <span class=\"corner-tr\" aria-hidden></span><span class=\"corner-bl\" aria-hidden></span>\n <div class=\"modal-h\">\n <div class=\"ic-m\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"8\" r=\"4\"/><path d=\"M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8\"/></svg>\n </div>\n <div class=\"ti\">上传头像<span>// 用于个人主页、评论与团队展示</span></div>\n </div>\n <div class=\"modal-b np-body\">\n\n <div class=\"av-up-preview-row\">\n <div class=\"av-up-preview\" id=\"av-up-preview\">李</div>\n <div class=\"av-up-preview-meta\">\n <div class=\"t\" id=\"av-up-preview-name\">当前头像 · 默认</div>\n <div class=\"d\" id=\"av-up-preview-info\">// 系统生成 · 取姓氏首字</div>\n </div>\n </div>\n\n <div class=\"upload-zone\" id=\"av-up-zone\" tabindex=\"0\" role=\"button\" aria-label=\"点击或拖入图片上传\">\n <span class=\"uz-ic\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"/><polyline points=\"17 8 12 3 7 8\"/><line x1=\"12\" y1=\"3\" x2=\"12\" y2=\"15\"/></svg>\n </span>\n <div><strong>点击选择</strong> · 或拖入图片</div>\n <span class=\"uz-hint\">JPG / PNG / WebP · ≤ 2 MB · 推荐 256 × 256</span>\n <input type=\"file\" id=\"av-up-file\" accept=\"image/jpeg,image/png,image/webp\" hidden>\n </div>\n\n <div class=\"av-up-rules\">\n <div class=\"li\">最大 2 MB · 长宽比建议 1:1 · 系统会自动裁切为圆形</div>\n <div class=\"li\">不要上传含他人肖像的图片,违规可能导致账号封停</div>\n </div>\n\n </div>\n <div class=\"modal-f\">\n <button class=\"btn\" type=\"button\" onclick=\"Shell.closeModal('avatar-up-bg')\">取消</button>\n <button class=\"btn btn-primary\" type=\"button\" id=\"av-up-confirm\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 12l5 5L20 6\"/></svg>\n 确认使用\n </button>\n </div>\n </div>\n</div>\n\n</div>\n<script src=\"/exact/assets/icons.js?v=2026052608\"></script>\n<script src=\"/exact/assets/shell.js?v=2026060101\"></script>\n<script>\nShell.render({\n active: 'settings',\n crumbs: [{ label: '工作台', href: 'index.html' }, { label: '设置' }]\n});\n\n/* ─── 配置 ─── */\nconst SECTIONS = ['sec-profile', 'sec-security', 'sec-notify', 'sec-pref', 'sec-display'];\nconst navLinks = document.querySelectorAll('.settings-nav a[data-jump]');\nlet suppressLeavePrompt = false;\n\nfunction logoutCurrentDevice() {\n const note = document.getElementById('logout-unsaved-note');\n if (note) note.hidden = !(typeof dirtyFields !== 'undefined' && dirtyFields.size > 0);\n Shell.openModal('logout-confirm-bg');\n requestAnimationFrame(() => document.getElementById('logout-confirm-cancel')?.focus());\n}\n\nfunction confirmLogout() {\n suppressLeavePrompt = true;\n Shell.closeModal('logout-confirm-bg');\n Shell.toast('已退出', '正在跳转登录页');\n setTimeout(() => location.href = 'login.html', 600);\n}\n\ndocument.getElementById('logout-confirm-ok')?.addEventListener('click', confirmLogout);\n\n/* ─── 1. 点击 nav → 只显示对应 section,其余隐藏 + 同步 URL hash ─── */\nfunction showSection(id) {\n if (!SECTIONS.includes(id)) id = SECTIONS[0];\n SECTIONS.forEach(sid => {\n const el = document.getElementById(sid);\n if (el) el.style.display = (sid === id) ? '' : 'none';\n });\n navLinks.forEach(a => a.classList.toggle('active', a.dataset.jump === id));\n if (location.hash.slice(1) !== id) {\n try { window.location.hash = id; } catch (e) {}\n }\n // 切换面板后回到顶端,避免长面板留下的滚动位置错乱\n window.scrollTo({ top: 0, behavior: 'instant' });\n}\n\nnavLinks.forEach(a => {\n a.addEventListener('click', e => {\n e.preventDefault();\n showSection(a.dataset.jump);\n a.focus();\n });\n a.addEventListener('keydown', e => {\n const idx = [...navLinks].indexOf(a);\n if (e.key === 'ArrowDown') { e.preventDefault(); navLinks[(idx + 1) % navLinks.length].focus(); }\n else if (e.key === 'ArrowUp') { e.preventDefault(); navLinks[(idx - 1 + navLinks.length) % navLinks.length].focus(); }\n else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); a.click(); }\n });\n});\n\n/* ─── 2. 初始加载:读 URL hash 显示对应 section(无 hash 时默认 sec-profile) ─── */\n(function initSection() {\n const hash = location.hash.slice(1);\n showSection(SECTIONS.includes(hash) ? hash : 'sec-profile');\n})();\n\n/* hash 外部变化(如浏览器前进后退)也跟着切 */\nwindow.addEventListener('hashchange', () => {\n const hash = location.hash.slice(1);\n if (SECTIONS.includes(hash)) showSection(hash);\n});\n\n/* ─── 4. 偏好 chip 选择 ─── */\nfunction bindChoice(containerId) {\n const ct = document.getElementById(containerId);\n if (!ct) return;\n ct.querySelectorAll('.pref-choice').forEach(c => {\n c.addEventListener('click', () => {\n ct.querySelectorAll('.pref-choice').forEach(x => x.classList.remove('selected'));\n c.classList.add('selected');\n markDirty(c);\n });\n });\n}\nbindChoice('pref-template');\nbindChoice('pref-subtitle');\n\ndocument.querySelectorAll('#pref-duration .dur-chip').forEach(c => {\n c.addEventListener('click', () => {\n document.querySelectorAll('#pref-duration .dur-chip').forEach(x => x.classList.remove('selected'));\n c.classList.add('selected');\n markDirty(c);\n });\n});\n\n/* ─── 5. dirty state · 追踪改了哪几节 + save btn 显示变更条数 ─── */\nconst dirtyFields = new Set(); // 改过的字段 id 集合\nconst dirtySections = new Set(); // 涉及的 section id 集合\nconst saveBtn = document.getElementById('save-btn');\nconst cancelBtn = document.getElementById('save-cancel');\nconst saveBtnDefaultLabel = saveBtn.innerHTML;\n\nfunction sectionOf(el) {\n const sec = el.closest('section[id]');\n return sec ? sec.id : null;\n}\n\nfunction syncSaveBtn() {\n const n = dirtyFields.size;\n if (n === 0) {\n saveBtn.disabled = true;\n cancelBtn.disabled = true;\n saveBtn.style.opacity = '.5';\n saveBtn.style.cursor = 'not-allowed';\n cancelBtn.style.opacity = '.5';\n cancelBtn.style.cursor = 'not-allowed';\n saveBtn.innerHTML = saveBtnDefaultLabel;\n } else {\n saveBtn.disabled = false;\n cancelBtn.disabled = false;\n saveBtn.style.opacity = '';\n saveBtn.style.cursor = '';\n cancelBtn.style.opacity = '';\n cancelBtn.style.cursor = '';\n saveBtn.innerHTML = `<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 12l5 5L20 6\"/></svg> 保存所有变更 <span style=\"opacity:.75;font-family:var(--font-mono);font-size:11px;margin-left:4px;\">· ${n} 项</span>`;\n }\n // nav 上加 dirty dot\n navLinks.forEach(a => a.classList.toggle('has-changes', dirtySections.has(a.dataset.jump)));\n}\n\nfunction markDirty(el) {\n const id = el.id || (el.dataset && el.dataset.v) || ('anon-' + Math.random().toString(36).slice(2, 8));\n if (dirtyFields.has(id)) return;\n dirtyFields.add(id);\n const sec = sectionOf(el);\n if (sec) dirtySections.add(sec);\n syncSaveBtn();\n}\nfunction clearDirty() {\n dirtyFields.clear();\n dirtySections.clear();\n syncSaveBtn();\n}\n\ndocument.querySelectorAll('[data-track], input[type=\"checkbox\"], select').forEach(el => {\n el.addEventListener('change', () => markDirty(el));\n if (el.tagName === 'INPUT' && el.type !== 'checkbox') el.addEventListener('input', () => markDirty(el));\n});\n\n/* ─── 6. 表单校验:必填 + 邮箱格式 ─── */\nconst VALIDATORS = {\n 'prof-name': v => v.trim().length === 0 ? '显示名称不能为空' : (v.length > 20 ? '显示名称最多 20 字' : null),\n 'prof-email': v => !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(v.trim()) ? '邮箱格式不正确' : null,\n 'prof-phone': v => !/^\\d{3}\\*{4}\\d{4}$|^1\\d{10}$/.test(v.trim()) ? '手机号格式不正确' : null,\n};\nfunction validate() {\n const errors = [];\n Object.entries(VALIDATORS).forEach(([id, fn]) => {\n const el = document.getElementById(id);\n if (!el) return;\n const err = fn(el.value);\n el.classList.toggle('invalid', !!err);\n if (err) errors.push(`${id}: ${err}`);\n });\n return errors;\n}\n\n/* ─── 7. 保存 / 取消 ─── */\nsaveBtn.addEventListener('click', () => {\n if (dirtyFields.size === 0) return;\n const errors = validate();\n if (errors.length) {\n Shell.toast('校验未通过', errors[0].split(': ')[1] + ' · 共 ' + errors.length + ' 项');\n return;\n }\n Shell.toast('设置已保存', `${dirtyFields.size} 项变更已生效 · 涉及 ${dirtySections.size} 个分区`);\n clearDirty();\n});\ncancelBtn.addEventListener('click', () => {\n if (dirtyFields.size === 0) return;\n if (confirm(`放弃 ${dirtyFields.size} 项未保存的变更?`)) location.reload();\n});\n\n/* ─── 8. 离开页面前提醒(有未保存变更时)─── */\nwindow.addEventListener('beforeunload', e => {\n if (!suppressLeavePrompt && dirtyFields.size > 0) {\n e.preventDefault();\n e.returnValue = '';\n }\n});\n\n/* ─── 9. invalid input 视觉反馈样式 ─── */\nconst _invalidStyle = document.createElement('style');\n_invalidStyle.textContent = `\n .input.invalid { border-color: var(--accent-crimson); box-shadow: 0 0 0 3px rgba(235,52,36,.12); }\n .input.invalid:focus { border-color: var(--accent-crimson); box-shadow: 0 0 0 3px rgba(235,52,36,.18); }\n`;\ndocument.head.appendChild(_invalidStyle);\n\n/* ─── 10. nav badge 计数自动同步(从 section 内 switch 开启数 / device 数等) ─── */\nfunction syncBadges() {\n const secNotify = document.getElementById('sec-notify');\n if (secNotify) {\n const onCount = secNotify.querySelectorAll('input[type=\"checkbox\"]:checked').length;\n const totalCount = secNotify.querySelectorAll('input[type=\"checkbox\"]').length;\n const badge = document.querySelector('.settings-nav a[data-jump=\"sec-notify\"] .nav-badge');\n if (badge) badge.textContent = `${onCount}/${totalCount}`;\n }\n const secSecurity = document.getElementById('sec-security');\n if (secSecurity) {\n const devCount = secSecurity.querySelectorAll('.device-row').length;\n const badge = document.querySelector('.settings-nav a[data-jump=\"sec-security\"] .nav-badge');\n if (badge) badge.textContent = `${devCount} 设备`;\n }\n}\nsyncBadges();\ndocument.querySelectorAll('#sec-notify input[type=\"checkbox\"]').forEach(cb => cb.addEventListener('change', syncBadges));\n\n/* ─── 11. 头像上传 modal ─── */\nconst DEFAULT_AVATAR = { kind: 'default', label: '李', name: '默认', info: '// 系统生成 · 取姓氏首字' };\nlet _draftAvatar = { ...DEFAULT_AVATAR };\nlet _currentAvatar = { ...DEFAULT_AVATAR };\n\nconst avPreview = document.getElementById('av-up-preview');\nconst avPreviewName = document.getElementById('av-up-preview-name');\nconst avPreviewInfo = document.getElementById('av-up-preview-info');\nconst avProfPreview = document.getElementById('prof-avatar-preview');\nconst avZone = document.getElementById('av-up-zone');\nconst avFile = document.getElementById('av-up-file');\nconst avConfirm = document.getElementById('av-up-confirm');\nconst avResetBtn = document.getElementById('prof-avatar-reset');\n\nfunction paintPreview() {\n if (_draftAvatar.kind === 'image') {\n avPreview.innerHTML = `<img src=\"${_draftAvatar.src}\" alt=\"头像预览\">`;\n } else {\n avPreview.innerHTML = '';\n avPreview.textContent = _draftAvatar.label || '李';\n }\n avPreviewName.textContent = `预览 · ${_draftAvatar.name}`;\n avPreviewInfo.textContent = _draftAvatar.info;\n}\n\nfunction applyAvatarTo(el, avatar) {\n if (avatar.kind === 'image') {\n el.innerHTML = `<img src=\"${avatar.src}\" alt=\"头像\" style=\"width:100%;height:100%;object-fit:cover;border-radius:50%;display:block;\">`;\n } else {\n el.innerHTML = '';\n el.textContent = avatar.label || '李';\n }\n}\n\navZone.addEventListener('click', () => avFile.click());\navZone.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); avFile.click(); } });\navZone.addEventListener('dragover', e => { e.preventDefault(); avZone.classList.add('dragover'); });\navZone.addEventListener('dragleave', () => avZone.classList.remove('dragover'));\navZone.addEventListener('drop', e => {\n e.preventDefault();\n avZone.classList.remove('dragover');\n const f = e.dataTransfer.files && e.dataTransfer.files[0];\n if (f) handleAvatarFile(f);\n});\navFile.addEventListener('change', () => { if (avFile.files[0]) handleAvatarFile(avFile.files[0]); });\n\nfunction handleAvatarFile(f) {\n if (!/^image\\/(jpeg|png|webp)$/.test(f.type)) {\n Shell.toast('格式不支持', '仅支持 JPG / PNG / WebP');\n return;\n }\n if (f.size > 2 * 1024 * 1024) {\n Shell.toast('文件过大', `${(f.size / 1024 / 1024).toFixed(2)} MB · 限 2 MB`);\n return;\n }\n const url = URL.createObjectURL(f);\n _draftAvatar = { kind: 'image', src: url, name: f.name, info: `// ${(f.size / 1024).toFixed(0)} KB · ${f.type.split('/')[1].toUpperCase()}` };\n paintPreview();\n}\n\navConfirm.addEventListener('click', () => {\n _currentAvatar = { ..._draftAvatar };\n applyAvatarTo(avProfPreview, _currentAvatar);\n Shell.closeModal('avatar-up-bg');\n Shell.toast('头像已更新', _currentAvatar.name);\n markDirty(avProfPreview);\n});\n\navResetBtn.addEventListener('click', () => {\n _currentAvatar = { ...DEFAULT_AVATAR };\n applyAvatarTo(avProfPreview, _currentAvatar);\n _draftAvatar = { ...DEFAULT_AVATAR };\n paintPreview();\n Shell.toast('已恢复默认头像', _currentAvatar.name);\n markDirty(avProfPreview);\n});\n\n// 打开 modal 时同步当前到 draft + preview\ndocument.querySelector('.av-actions button.btn-sm').addEventListener('click', () => {\n _draftAvatar = { ..._currentAvatar };\n paintPreview();\n});\n\npaintPreview();\n\n/* ─── 12. 实时邮箱 / 名称 input 校验反馈(blur 时显示错) ─── */\n['prof-name', 'prof-email', 'prof-phone'].forEach(id => {\n const el = document.getElementById(id);\n if (!el || !VALIDATORS[id]) return;\n el.addEventListener('blur', () => {\n const err = VALIDATORS[id](el.value);\n el.classList.toggle('invalid', !!err);\n });\n el.addEventListener('input', () => {\n if (el.classList.contains('invalid')) {\n const err = VALIDATORS[id](el.value);\n if (!err) el.classList.remove('invalid');\n }\n });\n});\n</script>\n<script src=\"/exact/assets/api-bridge.js?v=2026060114\"></script>\n</body>\n</html>\n",
|
||
"team": "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta name=\"x-airshelf-exact-source\" content=\"team.html\">\n<meta charset=\"utf-8\">\n<title>团队 · Airshelf</title>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"/exact/assets/restraint.css?v=2026060101\">\n<style>\n /* ─── 团队信息卡(深色 banner · 上标题行 + 下统计行)─── */\n /* 顶部行:banner(左)+ 团队动态(右),与下方 team-grid 同列宽对齐 */\n .team-top { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 24px; margin-bottom: 24px; align-items: stretch; }\n\n .team-banner {\n background: var(--accent-black);\n color: var(--accent-white);\n padding: 22px 28px 24px;\n position: relative;\n border: 1px solid var(--accent-black);\n border-radius: var(--r-md);\n }\n\n /* ─── 团队动态卡(贴 banner 右边)─── */\n .team-feed { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 16px 18px; display: flex; flex-direction: column; min-width: 0; }\n .team-feed .h { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }\n .team-feed .h h3 { font-size: 13.5px; font-weight: 600; margin: 0; }\n .team-feed .h .ct { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); }\n .team-feed .h .more { margin-left: auto; font-family: var(--font-mono); font-size: 11px; color: var(--heat); text-decoration: none; cursor: pointer; }\n .team-feed .feed-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; max-height: 240px; padding-right: 4px; scrollbar-width: thin; }\n .team-feed .feed-list::-webkit-scrollbar { width: 4px; }\n .team-feed .feed-list::-webkit-scrollbar-thumb { background: var(--border-faint); border-radius: 2px; }\n .team-feed .feed-item { display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: 10px; align-items: start; }\n .team-feed .feed-item .av { width: 24px; height: 24px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: grid; place-items: center; font-size: 11px; font-weight: 600; color: var(--accent-black); }\n .team-feed .feed-item .txt { font-size: 12.5px; line-height: 1.45; color: var(--accent-black); min-width: 0; }\n .team-feed .feed-item .txt .who { font-weight: 600; }\n .team-feed .feed-item .txt .act { color: var(--black-alpha-56); margin: 0 3px; }\n .team-feed .feed-item .txt .obj { color: var(--heat); }\n .team-feed .feed-item .txt .obj-money { color: var(--accent-forest); font-variant-numeric: tabular-nums; font-family: var(--font-mono); }\n .team-feed .feed-item .ts { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); margin-top: 2px; letter-spacing: .02em; }\n /* 4 个装订线小十字(2 个 pseudo + 2 个 span)*/\n .team-banner::before, .team-banner::after,\n .team-banner > .corner-tr, .team-banner > .corner-bl {\n content: ''; position: absolute; width: 14px; height: 14px;\n background: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 21' fill='%23e8e8e8'%3E%3Cpath d='M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z'/%3E%3C/svg%3E\") no-repeat center;\n background-size: contain; pointer-events: none;\n }\n .team-banner::before { top: -7px; left: -7px; }\n .team-banner::after { bottom: -7px; right: -7px; }\n .team-banner > .corner-tr { top: -7px; right: -7px; }\n .team-banner > .corner-bl { bottom: -7px; left: -7px; }\n\n /* 第 1 行:标题 + 主操作 */\n .banner-head { display: flex; align-items: flex-start; gap: 20px; }\n .banner-id { flex: 1; min-width: 0; }\n .banner-id .lbl { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.55); letter-spacing: .06em; text-transform: uppercase; }\n .banner-id .nm { font-size: 22px; font-weight: 700; letter-spacing: -.012em; margin-top: 4px; display: flex; align-items: baseline; gap: 10px; }\n .banner-id .nm .tag { font-size: 10.5px; font-family: var(--font-mono); padding: 2px 8px; background: rgba(255,255,255,.12); border-radius: var(--r-pill); letter-spacing: .04em; font-weight: 500; }\n .banner-id .meta { font-size: 12px; color: rgba(255,255,255,.5); margin-top: 6px; font-family: var(--font-mono); letter-spacing: .02em; }\n .banner-actions { display: flex; gap: 8px; flex-shrink: 0; }\n .banner-actions .btn { background: var(--accent-white); color: var(--accent-black); border-color: var(--accent-white); }\n .banner-actions .btn:hover { background: var(--background-base); }\n .banner-actions .btn-ghost { background: transparent; color: var(--accent-white); border: 1px solid rgba(255,255,255,.25); }\n .banner-actions .btn-ghost:hover { background: rgba(255,255,255,.08); color: var(--accent-white); }\n\n /* 分隔线 */\n .banner-divider { height: 1px; background: rgba(255,255,255,.1); margin: 20px 0 18px; }\n\n /* 第 2 行:4 列统计 */\n .banner-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px; }\n .banner-stats .stat { min-width: 0; }\n .banner-stats .stat .lbl { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.55); letter-spacing: .06em; text-transform: uppercase; }\n .banner-stats .stat .v { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -.012em; margin-top: 6px; }\n /* color 必须显式写,否则会被 restraint.css 全局 .stat .v 的 color: var(--accent-black) 覆盖成黑字 */\n .banner-stats .stat .v { color: var(--accent-white); }\n .banner-stats .stat .v.warn { color: #FFB870; }\n .banner-stats .stat .sub { font-size: 11px; color: rgba(255,255,255,.5); margin-top: 4px; font-family: var(--font-mono); letter-spacing: .02em; }\n\n /* ─── 主体两栏 ─── */\n .team-grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 24px; align-items: start; }\n\n .pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 20px; margin-bottom: 16px; }\n .pane h3 { font-size: 14px; font-weight: 600; margin-bottom: 14px; display: flex; align-items: center; gap: 8px; }\n .pane h3 .ct { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); font-weight: 400; }\n .pane h3 .spacer { margin-left: auto; }\n\n /* ─── 成员表 ─── */\n .members-table .av { width: 32px; height: 32px; border-radius: 50%; background: var(--background-lighter); display: inline-grid; place-items: center; font-weight: 600; font-size: 13px; color: var(--accent-black); border: 1px solid var(--border-faint); }\n .members-table .who { display: flex; align-items: center; gap: 10px; }\n .members-table .nm { font-weight: 500; font-size: 13.5px; line-height: 1.2; }\n .members-table .em { font-size: 11.5px; color: var(--black-alpha-48); font-family: var(--font-mono); }\n .members-table .role-pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: var(--r-pill); font-size: 11px; font-weight: 500; }\n .members-table .role-pill .dot { width: 6px; height: 6px; border-radius: 50%; }\n .members-table .role-super { background: var(--heat-12); color: var(--heat); }\n .members-table .role-super .dot { background: var(--heat); }\n .members-table .role-admin { background: rgba(30,64,175,.1); color: #1E40AF; }\n .members-table .role-admin .dot { background: #1E40AF; }\n .members-table .role-member { background: var(--background-lighter); color: var(--black-alpha-56); }\n .members-table .role-member .dot { background: var(--black-alpha-56); }\n .members-table .quota-cell { font-variant-numeric: tabular-nums; font-family: var(--font-mono); font-size: 12px; }\n .members-table .quota-cell .lbl { color: var(--black-alpha-48); }\n .members-table .quota-cell .v { color: var(--accent-black); font-weight: 600; }\n .members-table .used-bar { width: 80px; height: 4px; background: var(--background-lighter); border-radius: 2px; overflow: hidden; margin-top: 4px; }\n .members-table .used-bar > span { display: block; height: 100%; background: var(--heat); }\n .members-table .used-bar > span.ok { background: var(--accent-forest); }\n .members-table .used-bar > span.warn { background: #B45309; }\n .members-table .acts { display: flex; gap: 4px; justify-content: flex-end; }\n .members-table .icon-btn-sm { width: 28px; height: 28px; display: inline-grid; place-items: center; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); cursor: pointer; color: var(--black-alpha-56); transition: all var(--t-base); }\n .members-table .icon-btn-sm:hover { color: var(--heat); border-color: var(--heat-20); }\n .members-table .icon-btn-sm svg { width: 14px; height: 14px; }\n .members-table .icon-btn-sm.danger:hover { color: var(--accent-crimson); border-color: var(--accent-crimson); }\n .members-table tr.pending td { opacity: .65; }\n .members-table tr.pending .nm::after { content: '· 待激活'; font-size: 11px; color: var(--black-alpha-48); margin-left: 6px; font-weight: 400; font-family: var(--font-mono); }\n\n /* ─── 角色权限矩阵 ─── */\n .perm-table { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--surface); border: 1px solid var(--border-muted); border-radius: var(--r-md); overflow: hidden; font-size: 12.5px; }\n .perm-table th, .perm-table td { padding: 8px 10px; border-bottom: 0; }\n .perm-table th { border-bottom: 1px solid var(--border-muted); background: var(--background-lighter); font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; text-align: left; }\n .perm-table th:not(:first-child), .perm-table td:not(:first-child) { text-align: center; }\n .perm-table tbody td:first-child { color: var(--accent-black); }\n .perm-table .yes { color: var(--accent-forest); font-weight: 600; }\n .perm-table .no { color: var(--black-alpha-32); }\n\n /* ─── 额度检查规则 ─── */\n .quota-rules { font-size: 12.5px; color: var(--black-alpha-56); line-height: 1.8; }\n .quota-rules .step { display: flex; gap: 10px; padding: 6px 0; align-items: flex-start; }\n .quota-rules .num { width: 18px; height: 18px; border-radius: 50%; background: var(--heat-12); color: var(--heat); font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; display: grid; place-items: center; flex: 0 0 18px; margin-top: 1px; }\n .quota-rules .v { color: var(--accent-black); font-weight: 500; }\n .quota-rules .formula { font-family: var(--font-mono); font-size: 11px; color: var(--heat); background: var(--heat-12); padding: 1px 6px; }\n\n /* ─── 邀请 modal ─── */\n .invite-modal { width: min(480px, 92vw); }\n .invite-modal .field { margin-bottom: 14px; }\n .invite-modal label.field-label { display: block; font-size: 12px; color: var(--black-alpha-56); margin-bottom: 6px; font-family: var(--font-mono); letter-spacing: .02em; }\n .invite-modal label.field-label .req { color: var(--accent-crimson); margin-left: 2px; }\n .role-choices { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }\n .role-choice { padding: 12px 14px; border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: all var(--t-base); }\n .role-choice:hover { background: var(--background-lighter); }\n .role-choice.selected { border-color: var(--heat); background: var(--heat-12); }\n .role-choice .title { font-size: 13px; font-weight: 600; color: var(--accent-black); }\n .role-choice .desc { font-size: 11px; color: var(--black-alpha-56); margin-top: 2px; font-family: var(--font-mono); letter-spacing: .02em; }\n .quota-input-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }\n .quota-input-row .input { font-variant-numeric: tabular-nums; }\n\n /* ─── 重置密码 modal ─── */\n .reset-pwd-modal { width: min(480px, 92vw); }\n .reset-pwd-modal .reset-pwd-warn {\n display: flex; align-items: flex-start; gap: 10px;\n padding: 10px 12px;\n background: var(--heat-12);\n border: 1px solid var(--heat-20);\n border-radius: var(--r-md);\n margin-bottom: 14px;\n font-size: 12.5px; color: var(--heat);\n line-height: 1.5;\n }\n .reset-pwd-modal .reset-pwd-warn svg { width: 16px; height: 16px; flex-shrink: 0; margin-top: 2px; }\n .reset-pwd-modal .btn-sm { height: 38px; padding: 0 12px; }\n\n /* ─── 全部团队动态 modal ─── */\n .feed-all-modal { width: min(640px, 92vw); max-width: min(640px, 92vw); position: relative; }\n .feed-all-modal .md-x {\n position: absolute; top: 14px; right: 16px;\n width: 28px; height: 28px;\n background: transparent; border: 0; border-radius: var(--r-sm);\n color: var(--black-alpha-56); cursor: pointer;\n display: grid; place-items: center;\n }\n .feed-all-modal .md-x:hover { background: var(--background-lighter); color: var(--accent-black); }\n .feed-all-modal .md-x svg { width: 14px; height: 14px; }\n .feed-all-filter {\n display: flex; align-items: center; gap: 6px;\n padding: 12px 20px;\n border-bottom: 1px solid var(--border-faint);\n background: var(--background-lighter);\n }\n .feed-all-filter .spacer { flex: 1; }\n .feed-all-filter .fa-meta { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .feed-all-filter .fa-chip {\n height: 26px; padding: 0 10px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-pill);\n font-size: 11.5px; color: var(--black-alpha-72);\n font-family: inherit; cursor: pointer;\n transition: border-color var(--t-base), background var(--t-base), color var(--t-base);\n }\n .feed-all-filter .fa-chip:hover { border-color: var(--heat-20); color: var(--heat); }\n .feed-all-filter .fa-chip.selected { background: var(--accent-black); border-color: var(--accent-black); color: var(--accent-white); }\n .feed-all-body { max-height: 56vh; overflow-y: auto; padding: 8px 20px 20px; }\n .feed-all-list { display: flex; flex-direction: column; gap: 10px; }\n .feed-all-list .feed-item {\n display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; gap: 12px;\n align-items: start;\n padding: 10px 0;\n border-bottom: 1px solid var(--border-faint);\n }\n .feed-all-list .feed-item:last-child { border-bottom: 0; }\n .feed-all-list .feed-item .av {\n width: 28px; height: 28px;\n background: var(--background-lighter); border: 1px solid var(--border-faint);\n border-radius: 50%; display: grid; place-items: center;\n font-size: 12px; font-weight: 600; color: var(--accent-black);\n }\n .feed-all-list .feed-item .txt { font-size: 13px; line-height: 1.5; color: var(--accent-black); }\n .feed-all-list .feed-item .txt .who { font-weight: 600; }\n .feed-all-list .feed-item .txt .act { color: var(--black-alpha-56); margin: 0 4px; }\n .feed-all-list .feed-item .txt .obj { color: var(--heat); }\n .feed-all-list .feed-item .ts { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; white-space: nowrap; padding-top: 2px; }\n .feed-all-list .feed-empty { padding: 40px 0; text-align: center; font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; }\n\n /* ─── 月限额 modal ─── */\n .limit-modal .limit-presets { display: flex; flex-wrap: wrap; gap: 6px; }\n .limit-modal .limit-presets .lp {\n height: 32px; padding: 0 12px;\n background: var(--surface);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-pill);\n font-size: 12px; font-family: var(--font-mono);\n color: var(--accent-black);\n cursor: pointer;\n font-variant-numeric: tabular-nums;\n transition: border-color var(--t-base), background var(--t-base), color var(--t-base);\n }\n .limit-modal .limit-presets .lp:hover { border-color: var(--heat-20); background: var(--heat-12); color: var(--heat); }\n .limit-modal .limit-presets .lp.selected { border-color: var(--heat); background: var(--heat-12); color: var(--heat); font-weight: 600; }\n .limit-modal .limit-info {\n margin-top: 14px;\n padding: 10px 12px;\n background: var(--background-lighter);\n border-radius: var(--r-md);\n font-size: 11.5px;\n display: flex; flex-direction: column; gap: 4px;\n }\n .limit-modal .limit-info .li-row { display: flex; align-items: baseline; gap: 8px; }\n .limit-modal .limit-info .lk { font-family: var(--font-mono); color: var(--black-alpha-48); letter-spacing: .02em; }\n .limit-modal .limit-info .lv { margin-left: auto; font-variant-numeric: tabular-nums; color: var(--accent-black); font-weight: 600; }\n .limit-modal .limit-info .lv.neg { color: var(--accent-crimson); }\n .limit-modal label.field-label .lbl-note { color: var(--black-alpha-48); font-weight: 400; margin-left: 2px; font-family: var(--font-mono); }\n .limit-modal input[type=number]::-webkit-outer-spin-button,\n .limit-modal input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }\n .limit-modal input[type=number] { -moz-appearance: textfield; }\n\n /* ─── 创建账户 modal ─── */\n .create-acct-modal label.field-label .lbl-note { color: var(--black-alpha-48); font-weight: 400; margin-left: 2px; font-family: var(--font-mono); }\n .create-acct-modal .btn-sm { height: 38px; padding: 0 12px; }\n .create-acct-modal .quota-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }\n .create-acct-modal .quota-cell { display: flex; flex-direction: column; gap: 4px; min-width: 0; }\n .create-acct-modal .quota-cell .qk { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }\n .create-acct-modal .quota-cell .input { font-variant-numeric: tabular-nums; height: 34px; padding: 0 10px; }\n .create-acct-modal input[type=number]::-webkit-outer-spin-button,\n .create-acct-modal input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }\n .create-acct-modal input[type=number] { -moz-appearance: textfield; }\n\n /* ─── 分享凭据 modal ─── */\n .share-acct-modal { width: min(480px, 92vw); }\n .share-acct-modal .cred-card {\n background: var(--background-lighter);\n border: 1px solid var(--border-faint);\n border-radius: var(--r-md);\n overflow: hidden;\n }\n .share-acct-modal .cred-row {\n display: grid;\n grid-template-columns: 80px minmax(0, 1fr) 28px;\n gap: 12px;\n align-items: center;\n padding: 12px 14px;\n border-bottom: 1px solid var(--border-faint);\n }\n .share-acct-modal .cred-row:last-child { border-bottom: 0; }\n .share-acct-modal .cred-row .ck { font-size: 11.5px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; }\n .share-acct-modal .cred-row .cv {\n font-size: 13px; color: var(--accent-black); font-weight: 500;\n overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n }\n .share-acct-modal .cred-row .cv.mono { font-family: var(--font-mono); letter-spacing: .04em; }\n .share-acct-modal .cred-copy {\n width: 28px; height: 28px;\n background: transparent; border: 1px solid var(--border-faint);\n border-radius: var(--r-sm); color: var(--black-alpha-56);\n display: grid; place-items: center; cursor: pointer;\n transition: border-color var(--t-base), color var(--t-base);\n }\n .share-acct-modal .cred-copy:hover { border-color: var(--heat-20); color: var(--heat); }\n .share-acct-modal .cred-copy svg { width: 13px; height: 13px; }\n .share-acct-modal .cred-rules {\n margin-top: 14px;\n font-family: var(--font-mono); font-size: 11px;\n color: var(--black-alpha-56);\n letter-spacing: .02em; line-height: 1.7;\n }\n .share-acct-modal .cred-rules .li { display: flex; gap: 8px; }\n .share-acct-modal .cred-rules .li::before { content: '//'; color: var(--black-alpha-32); flex: 0 0 auto; }\n\n /* ─── 编辑成员 modal ─── */\n .edit-member-modal .ti .em-sep { color: var(--black-alpha-32); font-weight: 400; margin: 0 2px; }\n .edit-member-modal .ti #edit-username { color: var(--accent-black); font-weight: 500; }\n .edit-member-modal label.field-label .lbl-note { color: var(--black-alpha-48); font-weight: 400; margin-left: 2px; font-family: var(--font-mono); }\n .edit-member-modal #edit-name-readonly { background: var(--background-lighter); color: var(--black-alpha-56); cursor: not-allowed; }\n .edit-member-modal .readonly-text { font-size: 13px; color: var(--accent-black); padding: 8px 0; line-height: 1.4; }\n .edit-member-modal .readonly-text .role-pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: var(--r-pill); font-size: 12px; font-weight: 500; background: var(--heat-12); color: var(--heat); }\n .edit-member-modal .readonly-text .role-pill .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--heat); }\n .edit-member-modal input[type=number]::-webkit-outer-spin-button,\n .edit-member-modal input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }\n .edit-member-modal input[type=number] { -moz-appearance: textfield; font-variant-numeric: tabular-nums; }\n</style>\n</head>\n<body>\n<div id=\"page\">\n\n<div class=\"page-head\">\n <div>\n <h1>团队管理</h1>\n <div class=\"sub\"><span class=\"mono\">// 成员 · 角色 · 额度 · 共享资产库</span></div>\n </div>\n <div class=\"actions\">\n <button class=\"btn btn-primary\" id=\"open-invite\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2\"/><circle cx=\"9\" cy=\"7\" r=\"4\"/><path d=\"M19 8v6M22 11h-6\"/></svg>\n 创建账户\n </button>\n </div>\n</div>\n\n<!-- 顶部行:团队 banner(左)+ 团队动态(右) -->\n<div class=\"team-top\">\n\n<!-- 团队信息 banner -->\n<div class=\"team-banner\">\n <span class=\"corner-tr\" aria-hidden></span><span class=\"corner-bl\" aria-hidden></span>\n\n <div class=\"banner-head\">\n <div class=\"banner-id\">\n <div class=\"lbl\">[ TEAM ]</div>\n <div class=\"nm\">小李的店 <span class=\"tag\">企业</span></div>\n <div class=\"meta\">// 团队 ID: T-2026-A8F2 · 创建于 2026-04-12 · 5 名成员</div>\n </div>\n <div class=\"banner-actions\">\n <button class=\"btn btn-sm\" onclick=\"location.href='account.html'\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 7v10M9 10h4a1.5 1.5 0 0 1 0 3h-3a1.5 1.5 0 0 0 0 3h4\"/></svg>\n 充值\n </button>\n <button class=\"btn btn-ghost btn-sm\" id=\"open-limit\">设置月限额</button>\n </div>\n </div>\n\n <div class=\"banner-divider\"></div>\n\n <div class=\"banner-stats\">\n <div class=\"stat\">\n <div class=\"lbl\">[ 充值余额 ]</div>\n <div class=\"v\">¥327.40</div>\n <div class=\"sub\">// 团队总池</div>\n </div>\n <div class=\"stat\">\n <div class=\"lbl\">[ 月限额 ]</div>\n <div class=\"v\" id=\"stat-limit\">¥3,000</div>\n <div class=\"sub\">// 自然月重置</div>\n </div>\n <div class=\"stat\">\n <div class=\"lbl\">[ 当月已用 ]</div>\n <div class=\"v\" id=\"stat-used\">¥162.60</div>\n <div class=\"sub\" id=\"stat-used-sub\">// 占月限 5.4%</div>\n </div>\n <div class=\"stat\">\n <div class=\"lbl\">[ 当月剩余 ]</div>\n <div class=\"v warn\" id=\"stat-left\">¥2,837.40</div>\n <div class=\"sub\" id=\"stat-left-sub\">// 还可生成约 280 个项目</div>\n </div>\n </div>\n</div>\n\n<!-- 团队动态(banner 右栏)-->\n<div class=\"team-feed\">\n <div class=\"h\">\n <h3>团队动态</h3>\n <span class=\"ct\">// 最近 12 条</span>\n <a class=\"more\" id=\"open-feed-all\" role=\"button\" tabindex=\"0\">全部 →</a>\n </div>\n <div class=\"feed-list\">\n <div class=\"feed-item\">\n <div class=\"av\">张</div>\n <div>\n <div class=\"txt\"><span class=\"who\">张运营</span><span class=\"act\">完成视频</span><span class=\"obj\">补水面膜 · v3</span></div>\n <div class=\"ts\">10 分钟前</div>\n </div>\n </div>\n <div class=\"feed-item\">\n <div class=\"av\">王</div>\n <div>\n <div class=\"txt\"><span class=\"who\">王小姐</span><span class=\"act\">上传到资产库</span><span class=\"obj\">林夕 · 主播图</span></div>\n <div class=\"ts\">28 分钟前</div>\n </div>\n </div>\n <div class=\"feed-item\">\n <div class=\"av\">李</div>\n <div>\n <div class=\"txt\"><span class=\"who\">小李</span><span class=\"act\">邀请新成员</span><span class=\"obj\">林新人</span></div>\n <div class=\"ts\">2 小时前</div>\n </div>\n </div>\n <div class=\"feed-item\">\n <div class=\"av\">陈</div>\n <div>\n <div class=\"txt\"><span class=\"who\">陈策划</span><span class=\"act\">创建项目</span><span class=\"obj\">蓝牙耳机 · 开箱测评</span></div>\n <div class=\"ts\">4 小时前</div>\n </div>\n </div>\n <div class=\"feed-item\">\n <div class=\"av\">张</div>\n <div>\n <div class=\"txt\"><span class=\"who\">张运营</span><span class=\"act\">采用故事板</span><span class=\"obj\">补水面膜 · 场 3 · v2</span></div>\n <div class=\"ts\">昨天 18:32</div>\n </div>\n </div>\n <div class=\"feed-item\">\n <div class=\"av\">李</div>\n <div>\n <div class=\"txt\"><span class=\"who\">小李</span><span class=\"act\">团队充值</span><span class=\"obj-money\">+¥500.00</span></div>\n <div class=\"ts\">昨天 11:02</div>\n </div>\n </div>\n <div class=\"feed-item\">\n <div class=\"av\">王</div>\n <div>\n <div class=\"txt\"><span class=\"who\">王小姐</span><span class=\"act\">删除资产</span><span class=\"obj\">透真防晒 · 旧版主图</span></div>\n <div class=\"ts\">2 天前</div>\n </div>\n </div>\n </div>\n</div>\n\n</div><!-- /.team-top -->\n\n<div class=\"team-grid\">\n <!-- 左:成员表 -->\n <div>\n <div class=\"pane\" style=\"padding: 0;\">\n <div style=\"display: flex; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--border-faint);\">\n <h3 style=\"margin: 0;\">成员列表 <span class=\"ct\">// 5 人 · 1 超管 / 1 团管 / 3 成员</span></h3>\n <span class=\"spacer\"></span>\n <input class=\"input\" id=\"member-search\" placeholder=\"搜索姓名 / 手机号\" style=\"height: 32px; font-size: 12px; width: 220px;\">\n </div>\n <table class=\"t members-table\" style=\"border: 0; border-radius: 0;\">\n <thead>\n <tr>\n <th>成员</th>\n <th>角色</th>\n <th>每日额度</th>\n <th>月度额度</th>\n <th style=\"width: 140px;\">当月已用</th>\n <th style=\"text-align: right; width: 88px;\">操作</th>\n </tr>\n </thead>\n <tbody id=\"members-tbody\">\n <!-- JS 注入 -->\n </tbody>\n </table>\n </div>\n </div>\n\n <!-- 右:权限矩阵 + 额度规则 -->\n <div>\n <div class=\"pane\">\n <h3>角色权限</h3>\n <div style=\"font-size: 12px; color: var(--black-alpha-48); margin-top: -10px; margin-bottom: 12px; font-family: var(--font-mono); letter-spacing: .02em;\">// PRD §10.2 权限矩阵节选</div>\n <table class=\"perm-table\">\n <thead>\n <tr><th>能力</th><th>超管</th><th>团管</th><th>成员</th></tr>\n </thead>\n <tbody>\n <tr><td>邀请 / 移除成员</td><td class=\"yes\">✓</td><td class=\"yes\">✓</td><td class=\"no\">—</td></tr>\n <tr><td>设置成员额度</td><td class=\"yes\">✓</td><td class=\"yes\">✓</td><td class=\"no\">—</td></tr>\n <tr><td>团队充值</td><td class=\"yes\">✓</td><td class=\"no\">—</td><td class=\"no\">—</td></tr>\n <tr><td>设置月限额</td><td class=\"yes\">✓</td><td class=\"no\">—</td><td class=\"no\">—</td></tr>\n <tr><td>编辑别人项目</td><td class=\"yes\">✓</td><td class=\"yes\">✓</td><td class=\"no\">—</td></tr>\n <tr><td>团队共享资产库管理</td><td class=\"yes\">✓</td><td class=\"yes\">✓</td><td class=\"no\">仅自传</td></tr>\n <tr><td>查看团队消费明细</td><td class=\"yes\">✓</td><td class=\"yes\">✓</td><td class=\"no\">仅自己</td></tr>\n <tr style=\"border-bottom: 0;\"><td>创建项目 / 用 AI 流程</td><td class=\"yes\">✓</td><td class=\"yes\">✓</td><td class=\"yes\">✓</td></tr>\n </tbody>\n </table>\n </div>\n\n </div>\n</div>\n\n<!-- 设置月限额 modal -->\n<div class=\"modal-bg\" id=\"limit-bg\" onclick=\"if(event.target===this)Shell.closeModal('limit-bg')\">\n <div class=\"modal invite-modal limit-modal\">\n <span class=\"corner-tr\" aria-hidden></span><span class=\"corner-bl\" aria-hidden></span>\n <div class=\"modal-h\">\n <div class=\"ic-m\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 7v10M9 10h4a1.5 1.5 0 0 1 0 3h-3a1.5 1.5 0 0 0 0 3h4\"/></svg>\n </div>\n <div class=\"ti\">设置月限额<span>// 自然月重置 · 仅超管可改</span></div>\n </div>\n <div class=\"modal-b\">\n <div class=\"field\">\n <label class=\"field-label\">月限额 ¥ <span class=\"lbl-note\">(-1 为不限)</span></label>\n <input class=\"input\" id=\"limit-input\" type=\"number\" inputmode=\"numeric\" placeholder=\"例: 3000\" style=\"font-variant-numeric: tabular-nums;\">\n </div>\n <div class=\"field\" style=\"margin-bottom: 0;\">\n <label class=\"field-label\">快捷选择</label>\n <div class=\"limit-presets\">\n <button type=\"button\" class=\"lp\" data-v=\"1000\">¥1,000</button>\n <button type=\"button\" class=\"lp\" data-v=\"3000\">¥3,000</button>\n <button type=\"button\" class=\"lp\" data-v=\"5000\">¥5,000</button>\n <button type=\"button\" class=\"lp\" data-v=\"10000\">¥10,000</button>\n <button type=\"button\" class=\"lp\" data-v=\"-1\">不限</button>\n </div>\n </div>\n <div class=\"limit-info\">\n <div class=\"li-row\"><span class=\"lk\">当月已用</span><span class=\"lv mono\" id=\"limit-info-used\">¥162.60</span></div>\n <div class=\"li-row\"><span class=\"lk\">本次调整后剩余</span><span class=\"lv mono\" id=\"limit-info-left\">¥2,837.40</span></div>\n </div>\n </div>\n <div class=\"modal-f\">\n <button class=\"btn\" type=\"button\" onclick=\"Shell.closeModal('limit-bg')\">取消</button>\n <button class=\"btn btn-primary\" type=\"button\" id=\"limit-save\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 12l5 5L20 6\"/></svg>\n 保存\n </button>\n </div>\n </div>\n</div>\n\n<!-- 重置密码 modal -->\n<div class=\"modal-bg\" id=\"reset-pwd-bg\" onclick=\"if(event.target===this)Shell.closeModal('reset-pwd-bg')\">\n <div class=\"modal invite-modal reset-pwd-modal\">\n <span class=\"corner-tr\" aria-hidden></span><span class=\"corner-bl\" aria-hidden></span>\n <div class=\"modal-h\">\n <div class=\"ic-m\" style=\"color: var(--heat); background: var(--heat-12);\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"11\" width=\"18\" height=\"11\" rx=\"2\"/><path d=\"M7 11V7a5 5 0 0 1 10 0v4\"/></svg>\n </div>\n <div class=\"ti\">重置登录密码 <span class=\"em-sep\" style=\"color: var(--black-alpha-32); margin: 0 2px;\">—</span> <span id=\"reset-pwd-name\" style=\"color: var(--accent-black); font-weight: 500;\">—</span><span>// 该成员当前会话会被强制下线</span></div>\n </div>\n <div class=\"modal-b\">\n <div class=\"reset-pwd-warn\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 8v4M12 16h.01\"/></svg>\n <span>旧密码即刻失效,该成员需用新密码重新登录</span>\n </div>\n <div class=\"field\" style=\"margin-bottom: 0;\">\n <label class=\"field-label\">新密码</label>\n <div style=\"display:flex; gap:8px;\">\n <input class=\"input\" id=\"reset-pwd-input\" autocomplete=\"off\" placeholder=\"≥ 8 位 · 含字母与数字\" style=\"flex:1; font-family:var(--font-mono); letter-spacing:.04em;\">\n <button class=\"btn btn-sm\" type=\"button\" id=\"reset-pwd-gen\" title=\"生成随机密码\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5\"/></svg>\n </button>\n </div>\n </div>\n </div>\n <div class=\"modal-f\">\n <button class=\"btn\" type=\"button\" onclick=\"Shell.closeModal('reset-pwd-bg')\">取消</button>\n <button class=\"btn btn-primary\" type=\"button\" id=\"reset-pwd-confirm\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 12l5 5L20 6\"/></svg>\n 确认重置\n </button>\n </div>\n </div>\n</div>\n\n<!-- 全部团队动态 modal -->\n<div class=\"modal-bg\" id=\"feed-all-bg\" onclick=\"if(event.target===this)Shell.closeModal('feed-all-bg')\">\n <div class=\"modal feed-all-modal\">\n <span class=\"corner-tr\" aria-hidden></span><span class=\"corner-bl\" aria-hidden></span>\n <div class=\"modal-h\">\n <div class=\"ic-m\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 12h4l3-9 4 18 3-9h4\"/></svg>\n </div>\n <div class=\"ti\">团队动态<span id=\"feed-all-count\">// 共 12 条</span></div>\n <button class=\"md-x\" type=\"button\" aria-label=\"关闭\" onclick=\"Shell.closeModal('feed-all-bg')\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\"><path d=\"M18 6L6 18M6 6l12 12\"/></svg>\n </button>\n </div>\n <div class=\"feed-all-filter\">\n <button type=\"button\" class=\"fa-chip selected\" data-filter=\"all\">全部</button>\n <button type=\"button\" class=\"fa-chip\" data-filter=\"project\">项目</button>\n <button type=\"button\" class=\"fa-chip\" data-filter=\"asset\">资产</button>\n <button type=\"button\" class=\"fa-chip\" data-filter=\"team\">团队</button>\n <span class=\"spacer\"></span>\n <span class=\"fa-meta mono\" id=\"feed-all-meta\">// 共 12 条</span>\n </div>\n <div class=\"modal-b feed-all-body\">\n <div class=\"feed-all-list\" id=\"feed-all-list\"><!-- JS 注入 --></div>\n </div>\n </div>\n</div>\n\n<!-- 创建账户 modal -->\n<div class=\"modal-bg\" id=\"invite-bg\" onclick=\"if(event.target===this)Shell.closeModal('invite-bg')\">\n <div class=\"modal invite-modal create-acct-modal\">\n <span class=\"corner-tr\" aria-hidden></span><span class=\"corner-bl\" aria-hidden></span>\n <div class=\"modal-h\">\n <div class=\"ic-m\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2\"/><circle cx=\"9\" cy=\"7\" r=\"4\"/><path d=\"M19 8v6M22 11h-6\"/></svg>\n </div>\n <div class=\"ti\">创建账户<span>// 直接生成账号 · 分享给成员登录</span></div>\n </div>\n <div class=\"modal-b\">\n <div class=\"field\">\n <label class=\"field-label\">用户名 <span class=\"req\">*</span></label>\n <div style=\"display:flex; gap:8px;\">\n <input class=\"input\" id=\"inv-username\" autocomplete=\"off\" placeholder=\"例: zhang.yunying\" style=\"flex:1;\">\n <button class=\"btn btn-sm\" type=\"button\" id=\"inv-suggest-name\" title=\"生成随机用户名\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5\"/></svg>\n </button>\n </div>\n </div>\n <div class=\"field\">\n <label class=\"field-label\">备注姓名(可选)</label>\n <input class=\"input\" id=\"inv-name\" placeholder=\"例: 张某\">\n </div>\n <div class=\"field\">\n <label class=\"field-label\">登录密码 <span class=\"req\">*</span></label>\n <div style=\"display:flex; gap:8px;\">\n <input class=\"input\" id=\"inv-password\" autocomplete=\"off\" placeholder=\"≥ 8 位 · 含字母与数字\" style=\"flex:1; font-family:var(--font-mono); letter-spacing:.04em;\">\n <button class=\"btn btn-sm\" type=\"button\" id=\"inv-gen-pwd\" title=\"生成随机密码\">\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 12a9 9 0 11-3-6.7L21 8M21 3v5h-5\"/></svg>\n </button>\n </div>\n </div>\n <div class=\"field\">\n <label class=\"field-label\">分配角色 <span class=\"req\">*</span></label>\n <div class=\"role-choices\">\n <div class=\"role-choice selected\" data-role=\"member\">\n <div class=\"title\">成员</div>\n <div class=\"desc\">// 创建项目 + 用资产</div>\n </div>\n <div class=\"role-choice\" data-role=\"admin\">\n <div class=\"title\">团管</div>\n <div class=\"desc\">// 管成员 + 改额度</div>\n </div>\n </div>\n </div>\n <div class=\"field\" style=\"margin-bottom: 0;\">\n <label class=\"field-label\">额度配置 <span class=\"lbl-note\">(¥ · -1 为不限)</span></label>\n <div class=\"quota-grid\">\n <label class=\"quota-cell\">\n <span class=\"qk\">每日额度 ¥</span>\n <input class=\"input\" id=\"inv-daily\" type=\"number\" inputmode=\"numeric\" value=\"100\">\n </label>\n <label class=\"quota-cell\">\n <span class=\"qk\">每月额度 ¥</span>\n <input class=\"input\" id=\"inv-monthly\" type=\"number\" inputmode=\"numeric\" value=\"2000\">\n </label>\n <label class=\"quota-cell\">\n <span class=\"qk\">总额度 ¥</span>\n <input class=\"input\" id=\"inv-total\" type=\"number\" inputmode=\"numeric\" value=\"-1\">\n </label>\n </div>\n </div>\n </div>\n <div class=\"modal-f\">\n <button class=\"btn\" type=\"button\" onclick=\"Shell.closeModal('invite-bg')\">取消</button>\n <button class=\"btn btn-primary\" type=\"button\" id=\"inv-send\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2\"/><circle cx=\"9\" cy=\"7\" r=\"4\"/><path d=\"M19 8v6M22 11h-6\"/></svg>\n 创建账户\n </button>\n </div>\n </div>\n</div>\n\n<!-- 账户创建成功 · 分享凭据弹窗 -->\n<div class=\"modal-bg\" id=\"invite-share-bg\" onclick=\"if(event.target===this)Shell.closeModal('invite-share-bg')\">\n <div class=\"modal invite-modal share-acct-modal\">\n <span class=\"corner-tr\" aria-hidden></span><span class=\"corner-bl\" aria-hidden></span>\n <div class=\"modal-h\">\n <div class=\"ic-m\" style=\"color: var(--accent-forest); background: rgba(34, 110, 51, .08);\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 12l5 5L20 6\"/></svg>\n </div>\n <div class=\"ti\">账户已创建<span>// 把下方凭据分享给成员 · 凭据仅展示一次</span></div>\n </div>\n <div class=\"modal-b\">\n <div class=\"cred-card\">\n <div class=\"cred-row\">\n <span class=\"ck\">登录地址</span>\n <span class=\"cv mono\" id=\"share-url\">https://airshelf.com/login</span>\n <button class=\"cred-copy\" type=\"button\" data-copy=\"share-url\" title=\"复制\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\"/><path d=\"M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1\"/></svg>\n </button>\n </div>\n <div class=\"cred-row\">\n <span class=\"ck\">用户名</span>\n <span class=\"cv mono\" id=\"share-username\">—</span>\n <button class=\"cred-copy\" type=\"button\" data-copy=\"share-username\" title=\"复制\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\"/><path d=\"M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1\"/></svg>\n </button>\n </div>\n <div class=\"cred-row\">\n <span class=\"ck\">初始密码</span>\n <span class=\"cv mono\" id=\"share-password\">—</span>\n <button class=\"cred-copy\" type=\"button\" data-copy=\"share-password\" title=\"复制\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\"/><path d=\"M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1\"/></svg>\n </button>\n </div>\n </div>\n <div class=\"cred-rules\">\n <div class=\"li\">建议成员首次登录后立即修改密码</div>\n <div class=\"li\">凭据请通过加密渠道(企微 / 飞书私聊)分享,不要发到公共群</div>\n </div>\n </div>\n <div class=\"modal-f\">\n <button class=\"btn\" type=\"button\" id=\"share-copy-all\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\"/><path d=\"M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1\"/></svg>\n 一键复制全部\n </button>\n <button class=\"btn btn-primary\" type=\"button\" onclick=\"Shell.closeModal('invite-share-bg')\">完成</button>\n </div>\n </div>\n</div>\n\n<!-- 编辑成员 modal -->\n<div class=\"modal-bg\" id=\"edit-member-bg\" onclick=\"if(event.target===this)Shell.closeModal('edit-member-bg')\">\n <div class=\"modal invite-modal edit-member-modal\">\n <span class=\"corner-tr\" aria-hidden></span><span class=\"corner-bl\" aria-hidden></span>\n <div class=\"modal-h\">\n <div class=\"ic-m\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7\"/><path d=\"M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z\"/></svg>\n </div>\n <div class=\"ti\" id=\"edit-title\">编辑成员 <span class=\"em-sep\">—</span> <span id=\"edit-username\">—</span></div>\n </div>\n <div class=\"modal-b\">\n <div class=\"field\">\n <label class=\"field-label\">用户名 <span class=\"lbl-note\">(无权修改)</span></label>\n <input class=\"input\" id=\"edit-name-readonly\" readonly tabindex=\"-1\">\n </div>\n <div class=\"field\">\n <label class=\"field-label\">角色 <span class=\"lbl-note\" id=\"edit-role-note\"></span></label>\n <div class=\"readonly-text\" id=\"edit-role-text\" hidden>—</div>\n <div class=\"role-choices\" id=\"edit-role-choices\" hidden>\n <div class=\"role-choice\" data-edit-role=\"member\">\n <div class=\"title\">成员</div>\n <div class=\"desc\">// 创建项目 + 用资产</div>\n </div>\n <div class=\"role-choice\" data-edit-role=\"admin\">\n <div class=\"title\">团管</div>\n <div class=\"desc\">// 管成员 + 改额度</div>\n </div>\n </div>\n </div>\n <div class=\"field\">\n <label class=\"field-label\">每日额度 ¥ <span class=\"lbl-note\">(-1 为不限)</span></label>\n <input class=\"input\" id=\"edit-daily\" type=\"number\" inputmode=\"numeric\" placeholder=\"例: 100\">\n </div>\n <div class=\"field\">\n <label class=\"field-label\">每月额度 ¥ <span class=\"lbl-note\">(-1 为不限)</span></label>\n <input class=\"input\" id=\"edit-monthly\" type=\"number\" inputmode=\"numeric\" placeholder=\"例: 2000\">\n </div>\n <div class=\"field\" style=\"margin-bottom: 0;\">\n <label class=\"field-label\">总额度 ¥ <span class=\"lbl-note\">(-1 为不限)</span></label>\n <input class=\"input\" id=\"edit-total\" type=\"number\" inputmode=\"numeric\" placeholder=\"例: -1\">\n </div>\n </div>\n <div class=\"modal-f\">\n <button class=\"btn\" type=\"button\" onclick=\"Shell.closeModal('edit-member-bg')\">取消</button>\n <button class=\"btn btn-primary\" type=\"button\" id=\"edit-save\">保存</button>\n </div>\n </div>\n</div>\n\n</div>\n<script src=\"/exact/assets/icons.js?v=2026052608\"></script>\n<script src=\"/exact/assets/shell.js?v=2026060101\"></script>\n<script>\nShell.render({\n active: 'team',\n crumbs: [{ label: '工作台', href: 'index.html' }, { label: '团队' }]\n});\n\n/* ─── 团队成员 mock + 渲染 ─── */\nconst ROLE_META = {\n super: { cls: 'role-super', label: '超管' },\n admin: { cls: 'role-admin', label: '团管' },\n member: { cls: 'role-member', label: '成员' },\n};\n\n/* PRD §10.2 权限矩阵 · 4 条精简到弹窗速览(grant + deny 各 2 条) */\nconst ROLE_PERMS = {\n super: [\n { ok: true, txt: '团队设置 · 月限额 / 名称' },\n { ok: true, txt: '充值 · 划拨团队总额度' },\n { ok: true, txt: '任命团管 · 调整任意成员额度' },\n { ok: true, txt: '查看团队全部消费明细 + 财务' },\n ],\n admin: [\n { ok: true, txt: '邀请 / 移除成员 + 设置额度' },\n { ok: true, txt: '管理团队共享资产库' },\n { ok: true, txt: '查看团队全部消费明细' },\n { ok: false, txt: '团队设置 / 充值 · 仅超管' },\n ],\n member: [\n { ok: true, txt: '创建项目 + 流水线操作' },\n { ok: true, txt: '上传 / 引用团队共享资产' },\n { ok: false, txt: '邀请 / 移除成员 · 仅超管 / 团管' },\n { ok: false, txt: '消费明细 · 仅本人可见' },\n ],\n};\n\nconst MEMBERS = [\n { id: 'u1', av: '李', name: '小李', email: 'li@shop.com', role: 'super', daily: 500, monthly: 10000, used: 162.60, usedToday: 0.45, lastActive: '15 分钟前', pending: false, creator: true,\n joinDate: '2026-04-12', inviter: '—', projectsActive: 2, projectsDone: 14, assetsUploaded: 32,\n activity: [\n { ts: '昨天 11:02', act: '团队充值', obj: '+¥500.00' },\n { ts: '2 天前', act: '邀请新成员', obj: '林新人' },\n { ts: '3 天前', act: '创建项目', obj: '春日新品 · 立体口红' },\n ],\n byStage: { video: 98.40, storyboard: 36.00, asset: 21.00, script: 7.20 },\n transactions: [\n { ts: '05.21 14:32', proj: '补水面膜 · v3', type: '视频片段 · 1 镜', amount: -0.45 },\n { ts: '05.20 18:21', proj: '透真防晒 · 通勤对比', type: '视频片段 · 6 镜', amount: -1.20 },\n { ts: '05.20 11:02', proj: '充值', type: '微信支付', amount: 500.00 },\n { ts: '05.19 16:08', proj: '补水面膜 · v3', type: '故事板 image-2', amount: -0.45 },\n { ts: '05.19 14:02', proj: '补水面膜 · v3', type: '脚本 LLM · 2.4k', amount: -0.04 },\n { ts: '05.19 13:38', proj: '补水面膜 · v3', type: '基础资产 · 5 张', amount: -1.05 },\n { ts: '05.18 15:42', proj: '咖啡冻干 · 早八', type: '故事板生成失败', amount: 0 },\n { ts: '05.17 10:30', proj: '瑜伽裤 · 通勤穿搭', type: '项目导出', amount: -3.20 },\n ] },\n { id: 'u2', av: '张', name: '张运营', email: 'zhang@shop.com', role: 'admin', daily: 300, monthly: 6000, used: 98.40, usedToday: 0.45, lastActive: '10 分钟前', pending: false,\n joinDate: '2026-04-18', inviter: '小李', projectsActive: 3, projectsDone: 8, assetsUploaded: 18,\n activity: [\n { ts: '10 分钟前', act: '完成视频', obj: '补水面膜 · v3' },\n { ts: '昨天 18:32', act: '采用故事板', obj: '场 3 · v2' },\n { ts: '2 天前', act: '创建项目', obj: '蓝牙耳机 · 开箱测评' },\n ],\n byStage: { video: 60.00, storyboard: 22.00, asset: 12.00, script: 4.40 },\n transactions: [\n { ts: '05.21 14:08', proj: '补水面膜 · v3', type: '视频片段 · 1 镜', amount: -0.45 },\n { ts: '05.20 21:42', proj: '蓝牙耳机 · 开箱测评', type: '视频片段 · 6 镜', amount: -1.20 },\n { ts: '05.20 16:00', proj: '蓝牙耳机 · 开箱测评', type: '故事板 image-2', amount: -0.45 },\n { ts: '05.19 11:18', proj: '补水面膜 · v3', type: '故事板 image-2', amount: -0.45 },\n { ts: '05.18 09:42', proj: '蓝牙耳机 · 开箱测评', type: '基础资产 · 4 张', amount: -0.84 },\n { ts: '05.17 14:38', proj: '蓝牙耳机 · 开箱测评', type: '脚本 LLM · 1.8k', amount: -0.03 },\n ] },\n { id: 'u3', av: '王', name: '王小姐', email: 'wang@shop.com', role: 'member', daily: 100, monthly: 2000, used: 45.20, usedToday: 0, lastActive: '28 分钟前', pending: false,\n joinDate: '2026-04-22', inviter: '小李', projectsActive: 1, projectsDone: 4, assetsUploaded: 12,\n activity: [\n { ts: '28 分钟前', act: '上传到资产库', obj: '林夕 · 主播图' },\n { ts: '2 天前', act: '删除资产', obj: '透真防晒 · 旧版主图' },\n { ts: '5 天前', act: '完成视频', obj: '透真防晒 · 通勤对比' },\n ],\n byStage: { video: 28.00, storyboard: 10.00, asset: 5.00, script: 2.20 },\n transactions: [\n { ts: '05.16 19:38', proj: '透真防晒 · 通勤对比', type: '视频片段 · 4 镜', amount: -0.80 },\n { ts: '05.16 11:42', proj: '透真防晒 · 通勤对比', type: '故事板 image-2', amount: -0.45 },\n { ts: '05.15 16:08', proj: '透真防晒 · 通勤对比', type: '基础资产 · 3 张', amount: -0.63 },\n { ts: '05.15 09:30', proj: '透真防晒 · 通勤对比', type: '脚本 LLM · 1.5k', amount: -0.02 },\n ] },\n { id: 'u4', av: '陈', name: '陈策划', email: 'chen@shop.com', role: 'member', daily: 100, monthly: 2000, used: 12.80, usedToday: 0, lastActive: '4 小时前', pending: false,\n joinDate: '2026-05-02', inviter: '张运营', projectsActive: 1, projectsDone: 1, assetsUploaded: 5,\n activity: [\n { ts: '4 小时前', act: '创建项目', obj: '蓝牙耳机 · 开箱测评' },\n { ts: '昨天', act: '完成视频', obj: '速食面 · 加班场景' },\n ],\n byStage: { video: 8.00, storyboard: 3.00, asset: 1.20, script: 0.60 },\n transactions: [\n { ts: '05.20 14:32', proj: '速食面 · 加班场景', type: '视频片段 · 3 镜', amount: -0.60 },\n { ts: '05.19 18:08', proj: '速食面 · 加班场景', type: '故事板 image-2', amount: -0.30 },\n { ts: '05.19 11:12', proj: '速食面 · 加班场景', type: '基础资产 · 2 张', amount: -0.42 },\n ] },\n { id: 'u5', av: '林', name: '林新人', email: '186****1102', role: 'member', daily: 100, monthly: 2000, used: 0, usedToday: 0, lastActive: '尚未激活', pending: true,\n joinDate: '2026-05-19', inviter: '小李', projectsActive: 0, projectsDone: 0, assetsUploaded: 0,\n activity: [],\n byStage: { video: 0, storyboard: 0, asset: 0, script: 0 },\n transactions: [] },\n];\n\nfunction fmtMoney(n) { return '¥' + n.toFixed(2).replace(/\\B(?=(\\d{3})+(?!\\d))/g, ','); }\nfunction usedClass(pct) { if (pct < 0.5) return 'ok'; if (pct < 0.85) return ''; return 'warn'; }\n\n/* ─── 当前操作者角色 + PRD §10.2 权限矩阵 · 必须在 renderMembers 之前声明 ─── */\nconst CURRENT_ROLE = 'super'; // 'super' | 'admin' | 'member'\nfunction canEditRole(target) {\n // 只有超管可以改成员角色,创建者(初始超管)不可降级,其它超管暂不支持改\n if (CURRENT_ROLE !== 'super') return false;\n if (target.creator) return false;\n return target.role !== 'super';\n}\nfunction canEditMember(target) {\n if (target.creator) return false;\n if (CURRENT_ROLE === 'super') return true;\n if (CURRENT_ROLE === 'admin') return target.role === 'member';\n return false;\n}\nfunction canResetPassword(target) {\n if (target.creator) return false;\n if (CURRENT_ROLE === 'super') return target.role !== 'super';\n if (CURRENT_ROLE === 'admin') return target.role === 'member';\n return false;\n}\nfunction canRemoveMember(target) {\n return canEditMember(target);\n}\n\nfunction renderMembers(filter = '') {\n const tb = document.getElementById('members-tbody');\n const list = MEMBERS.filter(m => {\n if (!filter) return true;\n const q = filter.toLowerCase();\n return m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q);\n });\n tb.innerHTML = list.map(m => {\n const r = ROLE_META[m.role];\n const pct = m.monthly > 0 ? m.used / m.monthly : 0;\n return `\n <tr data-id=\"${m.id}\" class=\"${m.pending ? 'pending' : ''}${m.creator ? ' creator-row' : ''}\">\n <td>\n <div class=\"who\">\n <div class=\"av\">${m.av}</div>\n <div>\n <div class=\"nm\">${m.name}${m.creator ? ' <span style=\"font-family:var(--font-mono);font-size:10px;color:var(--black-alpha-48);\">· 创建者</span>' : ''}</div>\n <div class=\"em\">${m.email}</div>\n </div>\n </div>\n </td>\n <td><span class=\"role-pill ${r.cls}\"><span class=\"dot\"></span>${r.label}</span></td>\n <td><span class=\"quota-cell\"><span class=\"v\">${fmtMoney(m.daily)}</span></span></td>\n <td><span class=\"quota-cell\"><span class=\"v\">${fmtMoney(m.monthly)}</span></span></td>\n <td>\n <div class=\"quota-cell\"><span class=\"v\">${fmtMoney(m.used)}</span> <span class=\"lbl\">/ ${(pct * 100).toFixed(0)}%</span></div>\n <div class=\"used-bar\"><span class=\"${usedClass(pct)}\" style=\"width: ${Math.min(100, pct * 100).toFixed(1)}%\"></span></div>\n </td>\n <td>\n <div class=\"acts\">\n ${m.creator\n ? '<span style=\"font-family:var(--font-mono);font-size:10.5px;color:var(--black-alpha-32);align-self:center;\">不可编辑</span>'\n : [\n canEditMember(m) ? `<button class=\"icon-btn-sm\" data-act=\"edit\" data-id=\"${m.id}\" title=\"编辑\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7\"/><path d=\"M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z\"/></svg>\n </button>` : '',\n canResetPassword(m) ? `<button class=\"icon-btn-sm\" data-act=\"reset-pwd\" data-id=\"${m.id}\" title=\"重置密码\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"3\" y=\"11\" width=\"18\" height=\"11\" rx=\"2\"/><path d=\"M7 11V7a5 5 0 0 1 10 0v4\"/></svg>\n </button>` : '',\n canRemoveMember(m) ? `<button class=\"icon-btn-sm danger\" data-act=\"remove\" data-id=\"${m.id}\" title=\"移出\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2\"/></svg>\n </button>` : '',\n ].join('')\n }\n </div>\n </td>\n </tr>\n `;\n }).join('');\n\n tb.querySelectorAll('[data-act=\"edit\"]').forEach(btn => {\n btn.addEventListener('click', e => {\n e.stopPropagation();\n openEdit(btn.dataset.id);\n });\n });\n tb.querySelectorAll('[data-act=\"reset-pwd\"]').forEach(btn => {\n btn.addEventListener('click', e => {\n e.stopPropagation();\n openResetPwd(btn.dataset.id);\n });\n });\n tb.querySelectorAll('[data-act=\"remove\"]').forEach(btn => {\n btn.addEventListener('click', e => {\n e.stopPropagation();\n const m = MEMBERS.find(x => x.id === btn.dataset.id);\n if (!m) return;\n if (confirm('确定将「' + m.name + '」移出团队?')) {\n const i = MEMBERS.findIndex(x => x.id === m.id);\n MEMBERS.splice(i, 1);\n Shell.toast('已移除成员', m.name);\n renderMembers(document.getElementById('member-search').value);\n }\n });\n });\n}\nrenderMembers();\n\ndocument.getElementById('member-search').addEventListener('input', e => {\n renderMembers(e.target.value);\n});\n\n/* ─── 创建账户 modal ─── */\nconst $username = document.getElementById('inv-username');\nconst $name = document.getElementById('inv-name');\nconst $password = document.getElementById('inv-password');\nconst $daily = document.getElementById('inv-daily');\nconst $monthly = document.getElementById('inv-monthly');\nconst $total = document.getElementById('inv-total');\n\nfunction genUsername() {\n const prefixes = ['user', 'team', 'shop', 'creator', 'editor'];\n const p = prefixes[Math.floor(Math.random() * prefixes.length)];\n const n = Math.floor(1000 + Math.random() * 9000);\n return `${p}.${n}`;\n}\nfunction genPassword() {\n // 12 位 · 字母 + 数字混合 · 排除易混淆字符(0/O/1/l/I)\n const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789';\n let s = '';\n for (let i = 0; i < 12; i++) s += chars[Math.floor(Math.random() * chars.length)];\n return s;\n}\nfunction parseQuota(v, fallback) {\n if (v === '' || v == null) return fallback;\n const n = Number(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\ndocument.getElementById('open-invite').addEventListener('click', () => {\n // 打开时填充建议用户名 + 默认密码,用户可改\n if (!$username.value.trim()) $username.value = genUsername();\n if (!$password.value.trim()) $password.value = genPassword();\n Shell.openModal('invite-bg');\n});\ndocument.querySelectorAll('#invite-bg .role-choice').forEach(c => {\n c.addEventListener('click', () => {\n document.querySelectorAll('#invite-bg .role-choice').forEach(x => x.classList.remove('selected'));\n c.classList.add('selected');\n });\n});\ndocument.getElementById('inv-suggest-name').addEventListener('click', () => {\n $username.value = genUsername();\n $username.focus();\n});\ndocument.getElementById('inv-gen-pwd').addEventListener('click', () => {\n $password.value = genPassword();\n});\n\ndocument.getElementById('inv-send').addEventListener('click', () => {\n const username = ($username.value || '').trim();\n const password = ($password.value || '').trim();\n const name = ($name.value || '').trim() || username;\n if (!username) { Shell.toast('请填用户名', '账户未创建'); $username.focus(); return; }\n if (!/^[A-Za-z0-9._-]{3,32}$/.test(username)) {\n Shell.toast('用户名格式不正确', '3–32 位 · 字母/数字/. _ -');\n $username.focus(); return;\n }\n if (!password) { Shell.toast('请填密码', '账户未创建'); $password.focus(); return; }\n if (password.length < 8 || !/[A-Za-z]/.test(password) || !/\\d/.test(password)) {\n Shell.toast('密码强度不够', '至少 8 位 · 含字母与数字');\n $password.focus(); return;\n }\n const role = document.querySelector('#invite-bg .role-choice.selected')?.dataset.role || 'member';\n const daily = parseQuota($daily.value, 100);\n const monthly = parseQuota($monthly.value, 2000);\n const totalQuota = parseQuota($total.value, -1);\n\n MEMBERS.push({\n id: 'u' + Date.now(),\n av: name[0] || username[0]?.toUpperCase() || 'U',\n name, username,\n email: username + '@airshelf.local',\n role,\n daily, monthly, totalQuota,\n used: 0, usedToday: 0,\n lastActive: '尚未激活',\n pending: true,\n });\n renderMembers();\n Shell.closeModal('invite-bg');\n Shell.toast('账户已创建', username + ' · 共享凭据已生成');\n\n // 填充并打开分享凭据弹窗\n document.getElementById('share-username').textContent = username;\n document.getElementById('share-password').textContent = password;\n Shell.openModal('invite-share-bg');\n\n // 重置表单\n $username.value = '';\n $name.value = '';\n $password.value = '';\n});\n\n/* ─── 分享凭据 · 复制 ─── */\nasync function copyText(text) {\n try {\n if (navigator.clipboard && navigator.clipboard.writeText) {\n await navigator.clipboard.writeText(text);\n return true;\n }\n } catch (e) { /* fallthrough to execCommand */ }\n // 兜底:textarea + execCommand\n const ta = document.createElement('textarea');\n ta.value = text;\n ta.style.position = 'fixed';\n ta.style.opacity = '0';\n document.body.appendChild(ta);\n ta.select();\n let ok = false;\n try { ok = document.execCommand('copy'); } catch (e) { ok = false; }\n document.body.removeChild(ta);\n return ok;\n}\ndocument.querySelectorAll('#invite-share-bg .cred-copy').forEach(btn => {\n btn.addEventListener('click', async () => {\n const target = btn.dataset.copy;\n const el = document.getElementById(target);\n if (!el) return;\n const ok = await copyText(el.textContent.trim());\n const lbl = target === 'share-url' ? '登录地址' : (target === 'share-username' ? '用户名' : '密码');\n Shell.toast(ok ? '已复制' : '复制失败', ok ? lbl : '请手动选中复制');\n });\n});\ndocument.getElementById('share-copy-all').addEventListener('click', async () => {\n const url = document.getElementById('share-url').textContent.trim();\n const u = document.getElementById('share-username').textContent.trim();\n const p = document.getElementById('share-password').textContent.trim();\n const text = `登录地址: ${url}\\n用户名: ${u}\\n初始密码: ${p}`;\n const ok = await copyText(text);\n Shell.toast(ok ? '已复制全部' : '复制失败', ok ? '可以粘贴到企微/飞书私聊' : '请手动复制');\n});\n\n/* ─── 编辑 modal ─── */\nconst ROLE_LABEL = { super: '超级管理员 · 创建者', admin: '副管理员', member: '成员' };\nlet editingId = null;\nfunction openEdit(id) {\n const m = MEMBERS.find(x => x.id === id);\n if (!m) return;\n editingId = id;\n document.getElementById('edit-username').textContent = m.name;\n document.getElementById('edit-name-readonly').value = m.name;\n\n // 角色:可编辑 → 显示 role-choices,只读 → 显示 pill\n const roleNote = document.getElementById('edit-role-note');\n const roleText = document.getElementById('edit-role-text');\n const roleChoices = document.getElementById('edit-role-choices');\n if (canEditRole(m)) {\n roleNote.textContent = '(可改)';\n roleText.hidden = true;\n roleChoices.hidden = false;\n roleChoices.querySelectorAll('.role-choice').forEach(c => {\n c.classList.toggle('selected', c.dataset.editRole === m.role);\n });\n } else {\n roleNote.textContent = m.creator ? '(创建者不可改)' : (CURRENT_ROLE === 'super' ? '' : '(仅超管可改)');\n roleText.hidden = false;\n roleChoices.hidden = true;\n const roleLabel = ROLE_LABEL[m.role] || '成员';\n roleText.innerHTML = '<span class=\"role-pill\"><span class=\"dot\"></span>' + roleLabel + '</span>';\n }\n\n document.getElementById('edit-daily').value = m.daily != null ? m.daily : -1;\n document.getElementById('edit-monthly').value = m.monthly != null ? m.monthly : -1;\n document.getElementById('edit-total').value = m.totalQuota != null ? m.totalQuota : -1;\n Shell.openModal('edit-member-bg');\n}\n// role chip 切换\ndocument.querySelectorAll('#edit-role-choices .role-choice').forEach(c => {\n c.addEventListener('click', () => {\n document.querySelectorAll('#edit-role-choices .role-choice').forEach(x => x.classList.remove('selected'));\n c.classList.add('selected');\n });\n});\n/* ─── 重置密码 modal ─── */\nlet _resetPwdTarget = null;\nfunction openResetPwd(id) {\n const m = MEMBERS.find(x => x.id === id);\n if (!m) return;\n if (!canResetPassword(m)) {\n Shell.toast('无权重置该账户密码', '当前角色权限不足');\n return;\n }\n _resetPwdTarget = m;\n document.getElementById('reset-pwd-name').textContent = m.name;\n document.getElementById('reset-pwd-input').value = genPassword(); // 复用创建账户的生成器\n Shell.openModal('reset-pwd-bg');\n}\ndocument.getElementById('reset-pwd-gen').addEventListener('click', () => {\n document.getElementById('reset-pwd-input').value = genPassword();\n});\ndocument.getElementById('reset-pwd-confirm').addEventListener('click', () => {\n if (!_resetPwdTarget) return;\n const pwd = document.getElementById('reset-pwd-input').value.trim();\n if (!pwd) { Shell.toast('请填写新密码', '或点刷新生成'); return; }\n if (pwd.length < 8 || !/[A-Za-z]/.test(pwd) || !/\\d/.test(pwd)) {\n Shell.toast('密码强度不够', '至少 8 位 · 含字母与数字');\n return;\n }\n const m = _resetPwdTarget;\n Shell.closeModal('reset-pwd-bg');\n // 复用「分享凭据」弹窗展示新密码,让管理员可一键复制给成员\n document.getElementById('share-username').textContent = m.username || m.email || m.name;\n document.getElementById('share-password').textContent = pwd;\n // 标题语境从「账户已创建」临时改为「密码已重置」\n const shareTi = document.querySelector('#invite-share-bg .ti');\n if (shareTi) shareTi.innerHTML = '密码已重置<span>// 把新凭据分享给 ' + m.name + ' · 旧密码已失效</span>';\n Shell.openModal('invite-share-bg');\n Shell.toast('密码已重置', m.name + ' · 请把新密码同步给成员');\n _resetPwdTarget = null;\n});\n\n// 分享凭据弹窗关闭后,把标题复位到「账户已创建」的默认文案,\n// 这样下次「创建账户」流程打开时不会残留「密码已重置」的语境\n(function () {\n const bg = document.getElementById('invite-share-bg');\n const DEFAULT_TI = '账户已创建<span>// 把下方凭据分享给成员 · 凭据仅展示一次</span>';\n const obs = new MutationObserver(() => {\n if (!bg.classList.contains('show')) {\n const ti = bg.querySelector('.ti');\n if (ti) ti.innerHTML = DEFAULT_TI;\n }\n });\n obs.observe(bg, { attributes: true, attributeFilter: ['class'] });\n})();\n\ndocument.getElementById('edit-save').addEventListener('click', () => {\n const m = MEMBERS.find(x => x.id === editingId);\n if (!m) return;\n const parseQuota = (v, fallback) => {\n if (v === '' || v == null) return fallback;\n const n = Number(v);\n return Number.isFinite(n) ? n : fallback;\n };\n // 如果允许改角色,读取新值\n if (canEditRole(m)) {\n const newRole = document.querySelector('#edit-role-choices .role-choice.selected')?.dataset.editRole;\n if (newRole && newRole !== m.role) {\n m.role = newRole;\n }\n }\n m.daily = parseQuota(document.getElementById('edit-daily').value, m.daily);\n m.monthly = parseQuota(document.getElementById('edit-monthly').value, m.monthly);\n m.totalQuota = parseQuota(document.getElementById('edit-total').value, m.totalQuota != null ? m.totalQuota : -1);\n Shell.closeModal('edit-member-bg');\n Shell.toast('已保存', m.name + ' · 已更新');\n renderMembers(document.getElementById('member-search').value);\n});\n\n/* ─── 设置月限额 modal ─── */\nconst TEAM_BUDGET = { limit: 3000, used: 162.60 };\nfunction fmtMoneyComma(n) {\n if (n < 0) return '不限';\n return '¥' + n.toFixed(2).replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',');\n}\nfunction refreshLimitInfo() {\n const inputEl = document.getElementById('limit-input');\n const raw = inputEl.value.trim();\n const v = raw === '' ? TEAM_BUDGET.limit : Number(raw);\n const left = v < 0 ? null : v - TEAM_BUDGET.used;\n document.getElementById('limit-info-used').textContent = fmtMoneyComma(TEAM_BUDGET.used);\n const leftEl = document.getElementById('limit-info-left');\n if (left == null) {\n leftEl.textContent = '不限';\n leftEl.classList.remove('neg');\n } else {\n leftEl.textContent = fmtMoneyComma(left);\n leftEl.classList.toggle('neg', left < 0);\n }\n document.querySelectorAll('#limit-bg .limit-presets .lp').forEach(b => {\n b.classList.toggle('selected', Number(b.dataset.v) === v);\n });\n}\nfunction applyBudgetToBanner() {\n document.getElementById('stat-limit').textContent = TEAM_BUDGET.limit < 0 ? '不限' : fmtMoneyComma(TEAM_BUDGET.limit);\n document.getElementById('stat-used').textContent = fmtMoneyComma(TEAM_BUDGET.used);\n const left = TEAM_BUDGET.limit < 0 ? null : TEAM_BUDGET.limit - TEAM_BUDGET.used;\n const leftEl = document.getElementById('stat-left');\n if (left == null) {\n leftEl.textContent = '不限';\n leftEl.classList.remove('warn');\n } else {\n leftEl.textContent = fmtMoneyComma(left);\n leftEl.classList.toggle('warn', left / TEAM_BUDGET.limit < 0.2);\n }\n // 占比 + 推算项目数\n const pct = TEAM_BUDGET.limit > 0 ? (TEAM_BUDGET.used / TEAM_BUDGET.limit * 100).toFixed(1) : 0;\n document.getElementById('stat-used-sub').textContent = TEAM_BUDGET.limit < 0 ? '// 不限' : `// 占月限 ${pct}%`;\n document.getElementById('stat-left-sub').textContent = TEAM_BUDGET.limit < 0\n ? '// 月限额不限'\n : (left != null ? `// 还可生成约 ${Math.max(0, Math.round(left / 10))} 个项目` : '');\n}\ndocument.getElementById('open-limit').addEventListener('click', () => {\n document.getElementById('limit-input').value = TEAM_BUDGET.limit;\n refreshLimitInfo();\n Shell.openModal('limit-bg');\n});\ndocument.getElementById('limit-input').addEventListener('input', refreshLimitInfo);\ndocument.querySelectorAll('#limit-bg .limit-presets .lp').forEach(b => {\n b.addEventListener('click', () => {\n document.getElementById('limit-input').value = b.dataset.v;\n refreshLimitInfo();\n });\n});\ndocument.getElementById('limit-save').addEventListener('click', () => {\n const raw = document.getElementById('limit-input').value.trim();\n if (raw === '') { Shell.toast('请填写月限额', '或选择「不限」'); return; }\n const v = Number(raw);\n if (!Number.isFinite(v)) { Shell.toast('数值不合法', '请输入数字'); return; }\n if (v >= 0 && v < TEAM_BUDGET.used) {\n if (!confirm(`新月限额 ${fmtMoneyComma(v)} 小于当月已用 ${fmtMoneyComma(TEAM_BUDGET.used)},仍要保存?`)) return;\n }\n TEAM_BUDGET.limit = v;\n applyBudgetToBanner();\n Shell.closeModal('limit-bg');\n Shell.toast('月限额已更新', v < 0 ? '不限' : fmtMoneyComma(v));\n});\n\n/* ─── 团队动态 · 全部 → modal ─── */\nconst FEED_ALL = [\n { who: '张运营', av: '张', cat: 'project', act: '完成视频', obj: '补水面膜 · v3', ts: '10 分钟前' },\n { who: '王小姐', av: '王', cat: 'asset', act: '上传到资产库', obj: '林夕 · 主播图', ts: '28 分钟前' },\n { who: '小李', av: '李', cat: 'team', act: '邀请新成员', obj: '林新人', ts: '2 小时前' },\n { who: '陈策划', av: '陈', cat: 'project', act: '创建项目', obj: '蓝牙耳机 · 开箱测评', ts: '4 小时前' },\n { who: '张运营', av: '张', cat: 'project', act: '采用故事板', obj: '补水面膜 · 场 3 · v2', ts: '昨天 18:32' },\n { who: '小李', av: '李', cat: 'team', act: '充值', obj: '¥500.00', ts: '昨天 16:08' },\n { who: '陈策划', av: '陈', cat: 'asset', act: '加入资产库', obj: '阿强 · 健身男', ts: '昨天 14:12' },\n { who: '王小姐', av: '王', cat: 'project', act: '完成视频', obj: '瑜伽裤 · 通勤穿搭', ts: '昨天 11:38' },\n { who: '张运营', av: '张', cat: 'asset', act: '上传商品图', obj: '南卡 Lite Pro 蓝牙耳机', ts: '2 天前' },\n { who: '小李', av: '李', cat: 'team', act: '调整月限额', obj: '¥3,000', ts: '2 天前' },\n { who: '陈策划', av: '陈', cat: 'project', act: '取消项目', obj: '速食面 · 加班场景 · v1', ts: '2 天前' },\n { who: '王小姐', av: '王', cat: 'asset', act: '删除资产', obj: '透真防晒 · 旧版主图', ts: '2 天前' },\n];\nlet _feedFilter = 'all';\nfunction renderFeedAll() {\n const list = _feedFilter === 'all' ? FEED_ALL : FEED_ALL.filter(f => f.cat === _feedFilter);\n const root = document.getElementById('feed-all-list');\n root.innerHTML = list.length === 0\n ? '<div class=\"feed-empty\">// 没有匹配的动态</div>'\n : list.map(f => `\n <div class=\"feed-item\">\n <div class=\"av\">${f.av}</div>\n <div class=\"txt\"><span class=\"who\">${f.who}</span><span class=\"act\">${f.act}</span><span class=\"obj\">${f.obj}</span></div>\n <div class=\"ts\">${f.ts}</div>\n </div>\n `).join('');\n document.getElementById('feed-all-meta').textContent = `// 共 ${list.length} 条`;\n document.getElementById('feed-all-count').textContent = `// 共 ${list.length} 条`;\n}\ndocument.getElementById('open-feed-all').addEventListener('click', () => {\n _feedFilter = 'all';\n document.querySelectorAll('#feed-all-bg .fa-chip').forEach(c => c.classList.toggle('selected', c.dataset.filter === 'all'));\n renderFeedAll();\n Shell.openModal('feed-all-bg');\n});\ndocument.querySelectorAll('#feed-all-bg .fa-chip').forEach(chip => {\n chip.addEventListener('click', () => {\n _feedFilter = chip.dataset.filter;\n document.querySelectorAll('#feed-all-bg .fa-chip').forEach(c => c.classList.toggle('selected', c === chip));\n renderFeedAll();\n });\n});\n\n/* ─── 成员行点击 → 打开编辑(创建者除外)─── */\ndocument.getElementById('members-tbody').addEventListener('click', e => {\n // 行内按钮已 stopPropagation,只剩单元格 / 头像 / 文本会冒泡上来\n if (e.target.closest('button')) return;\n const tr = e.target.closest('tr[data-id]');\n if (!tr) return;\n const m = MEMBERS.find(x => x.id === tr.dataset.id);\n if (!m || m.creator) return;\n openEdit(tr.dataset.id);\n});\n// 行 hover 提示可点击\nconst styleHint = document.createElement('style');\nstyleHint.textContent = `\n #members-tbody tr[data-id]:not(.creator-row) { cursor: pointer; }\n #members-tbody tr[data-id]:not(.creator-row):hover { background: var(--background-lighter); }\n`;\ndocument.head.appendChild(styleHint);\n\n</script>\n<script src=\"/exact/assets/api-bridge.js?v=2026060114\"></script>\n</body>\n</html>\n"
|
||
};
|