AirShelf/电商AI平台/image-optimize.html
iye 086d92991e
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 7s
统一 Airshelf 界面组件与图标
2026-05-27 12:29:41 +08:00

1686 lines
65 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>图片创作 · Airshelf</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/restraint.css?v=2026052607">
<style>
/* viewport-fit · 工作台铺满 */
.app { height: 100vh; overflow: hidden; }
main { display: flex; flex-direction: column; min-height: 0; }
#page-content { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0; }
/* ===== 整体两栏 · 左会话列表 + 中央对话流 ===== */
.io-app {
flex: 1; min-height: 0;
display: grid;
grid-template-columns: 240px 1fr;
}
@media (max-width: 1100px) { .io-app { grid-template-columns: 200px 1fr; } }
/* ========== 左 · 会话栏 ========== */
.io-side {
border-right: 1px solid var(--border-faint);
background: var(--surface);
display: flex; flex-direction: column;
min-height: 0; overflow: hidden;
}
.io-side-h {
display: flex; align-items: center; gap: 8px;
padding: 14px 14px 10px;
border-bottom: 1px solid var(--border-faint);
}
.io-side-h .ti { font-size: 13.5px; font-weight: 600; color: var(--accent-black); }
.io-side-h .back-pill {
display: inline-flex; align-items: center; gap: 6px;
height: 28px; padding: 0 12px 0 8px;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-pill);
color: var(--accent-black);
font-size: 12.5px; font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: background var(--t-base), border-color var(--t-base), color var(--t-base);
}
.io-side-h .back-pill:hover {
background: var(--heat-12);
border-color: var(--heat-20);
color: var(--heat);
}
.io-side-h .back-pill svg { width: 14px; height: 14px; }
.io-side-h .fold {
margin-left: auto;
width: 26px; height: 26px;
background: transparent; border: 0; border-radius: var(--r-sm);
display: grid; place-items: center;
color: var(--black-alpha-48); cursor: pointer;
transition: background var(--t-base), color var(--t-base);
}
.io-side-h .fold:hover { background: var(--black-alpha-4); color: var(--accent-black); }
.io-side-h .fold svg { width: 14px; height: 14px; }
.io-new-conv {
margin: 10px 12px 0;
height: 36px;
display: inline-flex; align-items: center; gap: 8px;
padding: 0 12px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
color: var(--accent-black);
font-size: 13px; font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
}
.io-new-conv:hover {
border-color: var(--heat-20); background: var(--heat-12); color: var(--heat);
}
.io-new-conv svg { width: 13px; height: 13px; }
.io-side-sec-h {
margin: 16px 14px 6px;
font-family: var(--font-mono); font-size: 10px;
color: var(--black-alpha-48);
letter-spacing: .08em;
text-transform: uppercase;
}
.io-conv-list {
flex: 1; min-height: 0;
overflow-y: auto;
padding: 0 6px 14px;
display: flex; flex-direction: column; gap: 2px;
}
.io-conv-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px;
border-radius: var(--r-sm);
cursor: pointer;
color: var(--accent-black);
transition: background var(--t-base);
position: relative;
}
.io-conv-item:hover { background: var(--background-lighter); }
.io-conv-item.active { background: var(--heat-12); }
.io-conv-item .thumb {
flex-shrink: 0;
width: 28px; height: 28px;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
background-size: cover; background-position: center;
display: grid; place-items: center;
color: var(--black-alpha-32);
}
.io-conv-item .thumb svg { width: 13px; height: 13px; }
.io-conv-item.default .thumb {
background: var(--accent-black); color: var(--accent-white); border-color: var(--accent-black);
}
.io-conv-item .nm {
flex: 1; min-width: 0;
font-size: 12.5px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.io-conv-item.active .nm { color: var(--heat); font-weight: 600; }
.io-conv-item .del {
display: none;
width: 22px; height: 22px;
background: transparent; border: 0; border-radius: var(--r-sm);
color: var(--black-alpha-48); cursor: pointer;
align-items: center; justify-content: center;
}
.io-conv-item:hover .del { display: inline-flex; }
.io-conv-item .del:hover { color: var(--accent-crimson); background: var(--crimson-bg, #fdebea); }
.io-conv-item .del svg { width: 11px; height: 11px; }
/* ========== 右 · 对话流 ========== */
.io-main {
display: flex; flex-direction: column;
min-height: 0;
position: relative;
}
.io-toolbar {
flex-shrink: 0;
display: flex; align-items: center; gap: 10px;
padding: 12px 28px;
border-bottom: 1px solid var(--border-faint);
background: var(--surface);
}
.io-toolbar .spacer { flex: 1; }
.io-toolbar .search-btn {
width: 32px; height: 32px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
color: var(--black-alpha-72);
cursor: pointer;
display: grid; place-items: center;
}
.io-toolbar .search-btn:hover { border-color: var(--heat-20); color: var(--heat); }
.io-toolbar .search-btn svg { width: 14px; height: 14px; }
.io-toolbar .tb-chip {
display: inline-flex; align-items: center; gap: 6px;
height: 32px; padding: 0 10px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
font-size: 12.5px; color: var(--black-alpha-72);
font-family: inherit; cursor: pointer;
transition: border-color var(--t-base), color var(--t-base);
}
.io-toolbar .tb-chip:hover { border-color: var(--heat-20); color: var(--heat); }
.io-toolbar .tb-chip svg { width: 10px; height: 10px; opacity: .6; }
/* 对话流主体 */
.io-stream {
flex: 1; min-height: 0;
overflow-y: auto;
padding: 28px 28px 220px; /* 底部留出输入框高度(避免被遮挡) */
background: var(--background-base);
}
@media (max-width: 1100px) { .io-stream { padding: 22px 18px 220px; } }
.io-stream-inner {
max-width: 1180px; margin: 0 auto;
display: flex; flex-direction: column; gap: 32px;
}
/* 单条对话(气泡式 · 提示词块 + 结果网格) */
.io-msg { display: flex; flex-direction: column; gap: 14px; }
.io-msg-prompt {
display: flex; align-items: flex-start; gap: 12px;
padding-left: 4px;
}
.io-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;
font-family: var(--font-mono); font-size: 13px;
}
.io-msg-prompt .quote svg { width: 13px; height: 13px; }
.io-msg-prompt .pt {
flex: 1; min-width: 0;
padding-top: 4px;
}
.io-msg-prompt .pt-text {
font-size: 14px; color: var(--accent-black);
line-height: 1.55;
word-break: break-word;
}
.io-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;
}
.io-msg-prompt .pt-tags .meta-chip {
padding: 2px 8px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
}
.io-msg-prompt .pt-tags .sep { color: var(--black-alpha-24); }
/* 结果网格 — 4 张/行 */
.io-msg-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
}
@media (max-width: 1280px) { .io-msg-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 900px) { .io-msg-grid { grid-template-columns: repeat(2, 1fr); } }
.io-cell {
position: relative;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
overflow: hidden;
cursor: pointer;
transition: border-color var(--t-base);
}
.io-cell:hover { border-color: var(--black-alpha-32); }
.io-cell.r-1-1 { aspect-ratio: 1 / 1; }
.io-cell.r-16-9 { aspect-ratio: 16 / 9; }
.io-cell.r-9-16 { aspect-ratio: 9 / 16; }
.io-cell.r-4-3 { aspect-ratio: 4 / 3; }
.io-cell.r-3-4 { aspect-ratio: 3 / 4; }
.io-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);
}
.io-cell.gen .ph-frame { animation: io-pulse 1.4s ease-in-out infinite; }
@keyframes io-pulse {
0%, 100% { opacity: 1; }
50% { opacity: .55; }
}
.io-cell.err {
border-color: var(--accent-crimson, #c43d3d);
}
.io-cell.err .ph-frame {
color: var(--accent-crimson, #c43d3d);
background: rgba(196, 61, 61, .05);
}
/* 右上 hover 操作组 */
.io-cell .cell-ops {
position: absolute; top: 6px; right: 6px;
display: flex; gap: 4px;
opacity: 0;
transition: opacity var(--t-base);
}
.io-cell:hover .cell-ops { opacity: 1; }
.io-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);
}
.io-cell .cell-ops button:hover { border-color: var(--heat); color: var(--heat); }
.io-cell .cell-ops button svg { width: 12px; height: 12px; }
/* 更多 · 下拉气泡(删除 / 加入资产库) */
.io-cell .cell-more-wrap { position: relative; }
.io-cell .cell-more-menu {
position: absolute; top: calc(100% + 4px); right: 0;
min-width: 132px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
box-shadow: 0 6px 24px rgba(0,0,0,.10);
padding: 4px;
display: none;
z-index: 12;
}
.io-cell .cell-more-wrap.open .cell-more-menu { display: block; }
.io-cell .cell-more-menu button {
width: 100%;
display: inline-flex !important; align-items: center; gap: 8px;
padding: 7px 10px !important;
background: transparent !important;
border: 0 !important; border-radius: var(--r-sm) !important;
font-size: 12.5px;
color: var(--accent-black) !important;
font-family: inherit;
text-align: left;
cursor: pointer;
backdrop-filter: none !important;
height: auto !important;
justify-content: flex-start !important;
}
.io-cell .cell-more-menu button:hover {
background: var(--background-lighter) !important;
color: var(--heat) !important;
}
.io-cell .cell-more-menu button.danger:hover {
color: var(--accent-crimson) !important;
background: var(--crimson-bg, #fdebea) !important;
}
.io-cell .cell-more-menu button svg { width: 13px !important; height: 13px !important; }
/* 操作行(重新编辑 / 再次生成 / ...) */
.io-msg-ops {
display: flex; gap: 8px;
padding-left: 4px;
}
.io-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);
}
.io-msg-ops button:hover {
border-color: var(--heat-20); color: var(--heat); background: var(--heat-12);
}
.io-msg-ops button.icon {
width: 30px; padding: 0;
justify-content: center;
}
.io-msg-ops button svg { width: 13px; height: 13px; }
/* 操作行 · 更多气泡(全部加入资产库 / 删除该批结果) */
.io-msg-ops .msg-more-wrap { position: relative; }
.io-msg-ops .msg-more-menu {
position: absolute; bottom: calc(100% + 6px); left: 0;
min-width: 168px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
box-shadow: 0 6px 24px rgba(0,0,0,.10);
padding: 4px;
display: none;
z-index: 12;
}
.io-msg-ops .msg-more-wrap.open .msg-more-menu { display: block; }
.io-msg-ops .msg-more-menu button {
width: 100%;
display: inline-flex !important; align-items: center; gap: 8px;
height: auto !important;
padding: 7px 10px !important;
background: transparent !important;
border: 0 !important; border-radius: var(--r-sm) !important;
font-size: 12.5px;
color: var(--accent-black) !important;
text-align: left;
justify-content: flex-start !important;
}
.io-msg-ops .msg-more-menu button:hover {
background: var(--background-lighter) !important;
color: var(--heat) !important;
}
.io-msg-ops .msg-more-menu button.danger:hover {
color: var(--accent-crimson) !important;
background: var(--crimson-bg, #fdebea) !important;
}
.io-msg-ops .msg-more-menu button svg { width: 13px !important; height: 13px !important; flex-shrink: 0; }
/* 重复加入资产库 · 确认弹窗 */
.io-dup-modal-bg {
position: fixed; inset: 0; z-index: 1200;
background: rgba(21, 20, 15, .42);
display: grid; place-items: center;
padding: 16px;
}
.io-dup-modal-bg[hidden] { display: none; }
.io-dup-modal {
width: min(420px, 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;
}
.io-dup-modal .dh {
display: flex; align-items: center; gap: 12px;
padding: 18px 20px 14px;
}
.io-dup-modal .dh .ic {
width: 36px; height: 36px; flex-shrink: 0;
border-radius: var(--r-md);
background: var(--heat-12); color: var(--heat);
display: grid; place-items: center;
}
.io-dup-modal .dh .ic svg { width: 18px; height: 18px; }
.io-dup-modal .dh .ti { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.io-dup-modal .dh .ti strong { font-size: 14.5px; color: var(--accent-black); font-weight: 600; }
.io-dup-modal .dh .ti .mono { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
.io-dup-modal .df {
display: flex; gap: 8px;
padding: 0 20px 18px;
justify-content: flex-end;
}
.io-dup-modal .df button {
height: 32px; padding: 0 14px;
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), background var(--t-base), color var(--t-base);
}
.io-dup-modal .df button:hover { border-color: var(--heat-20); background: var(--heat-12); color: var(--heat); }
.io-dup-modal .df button.primary {
background: var(--heat); border-color: var(--heat); color: var(--accent-white);
}
.io-dup-modal .df button.primary:hover { filter: brightness(1.05); color: var(--accent-white); }
/* 空态 */
.io-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;
}
.io-empty .badge {
font-family: var(--font-mono); font-size: 11px;
letter-spacing: .08em; color: var(--black-alpha-48);
text-transform: uppercase;
}
.io-empty h2 {
font-size: 22px; font-weight: 600;
color: var(--accent-black);
letter-spacing: -.015em;
}
.io-empty p { font-size: 13px; max-width: 460px; line-height: 1.6; }
.io-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);
}
.io-empty .ic svg { width: 28px; height: 28px; }
.io-empty .examples {
margin-top: 10px;
display: flex; flex-wrap: wrap; gap: 8px; justify-content: center;
max-width: 720px;
}
.io-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);
}
.io-empty .examples .ex:hover {
border-color: var(--heat-20); color: var(--heat); background: var(--heat-12);
}
/* 浮动 "回到底部" 按钮 */
.io-jump-bottom {
position: absolute;
bottom: 200px; left: 50%;
transform: translateX(-50%) translateY(0);
display: inline-flex; align-items: center; gap: 4px;
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;
box-shadow: 0 4px 16px rgba(0,0,0,.06);
z-index: 6;
opacity: 0; pointer-events: none;
transition: opacity var(--t-base), transform var(--t-base);
}
.io-jump-bottom.show {
opacity: 1; pointer-events: auto;
transform: translateX(-50%) translateY(-6px);
}
.io-jump-bottom:hover { color: var(--heat); border-color: var(--heat-20); }
.io-jump-bottom svg { width: 12px; height: 12px; }
/* ========== 底部 · fixed 输入栏 ========== */
.io-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;
}
@media (max-width: 1100px) { .io-input-wrap { padding: 14px 18px 18px; } }
.io-input {
max-width: 1180px; 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);
}
.io-input:focus-within { border-color: var(--heat-40); }
/* 上行 · 参考图 + 加号按钮 (同一 flex 行, 视觉同尺寸) */
.io-input-top {
display: flex; align-items: center; gap: 8px;
flex-wrap: wrap;
}
.io-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);
}
.io-input-top .add-btn:hover { border-color: var(--heat-40); color: var(--heat); background: var(--heat-12); }
.io-input-top .add-btn svg { width: 22px; height: 22px; }
/* 中行 · textarea 满宽 */
.io-input textarea#io-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;
}
.io-input textarea#io-input-text::placeholder { color: var(--black-alpha-48); }
/* 发送按钮 (放底栏右端) */
.io-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;
}
.io-input .send-btn:hover { filter: brightness(1.05); }
.io-input .send-btn:disabled { opacity: .4; cursor: not-allowed; }
.io-input .send-btn svg { width: 15px; height: 15px; }
/* 参考图缩略 · 容器扁平化, 让子项直接参与 .io-input-top 的 flex 行 */
.io-input-refs { display: contents; }
.io-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;
}
.io-input-ref img { width: 100%; height: 100%; object-fit: cover; }
.io-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;
}
.io-input-ref .x svg { width: 10px; height: 10px; }
/* 输入栏底部 · 参数胶囊行 (比例 / 模型 / 张数) */
.io-input-bottom {
display: flex; align-items: center; gap: 6px;
flex-wrap: wrap;
}
.io-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);
}
.io-input-bottom .param[hidden] { display: none; }
.io-input-bottom .param:hover { background: var(--surface); border-color: var(--border-faint); }
.io-input-bottom .param.active { background: var(--heat-12); color: var(--heat); }
.io-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;
}
.io-input-bottom .param.active .lbl-mono { color: var(--heat); }
.io-input-bottom .param svg { width: 10px; height: 10px; opacity: .6; }
.io-input-bottom .right-meta {
margin-left: auto;
font-family: var(--font-mono); font-size: 10.5px;
color: var(--black-alpha-48); letter-spacing: .02em;
}
.io-input-bottom .right-meta .val { color: var(--accent-black); }
/* 参数下拉气泡 */
.io-param-menu {
position: absolute; bottom: calc(100% + 6px); left: -2px;
min-width: 140px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
box-shadow: 0 6px 24px rgba(0,0,0,.08);
padding: 4px;
display: none;
z-index: 30;
}
.io-input-bottom .param.open .io-param-menu { display: block; }
.io-param-menu .mi {
display: flex; align-items: center; gap: 8px;
padding: 7px 10px;
border-radius: var(--r-sm);
font-size: 12.5px;
color: var(--accent-black);
cursor: pointer;
}
.io-param-menu .mi:hover { background: var(--background-lighter); }
.io-param-menu .mi.selected { color: var(--heat); font-weight: 600; }
.io-param-menu .mi .mi-sub {
margin-left: auto;
font-family: var(--font-mono); font-size: 10px;
color: var(--black-alpha-48); letter-spacing: .02em;
}
.io-param-menu .mi .mi-check {
width: 12px; height: 12px; opacity: 0;
}
.io-param-menu .mi.selected .mi-check { opacity: 1; }
/* 隐藏 file input */
#io-file-input { display: none; }
</style>
</head>
<body>
<div id="page">
<div class="io-app">
<!-- ===== 左 · 会话列表 ===== -->
<aside class="io-side">
<div class="io-side-h">
<button class="back-pill" type="button" onclick="history.length > 1 ? history.back() : location.href='asset-factory.html'" title="返回">
<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>
<span>返回</span>
</button>
<button class="fold" type="button" title="折叠侧栏">
<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>
</button>
</div>
<button class="io-new-conv" type="button" id="io-new-conv">
<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>
新对话
</button>
<div class="io-side-sec-h">默认</div>
<div class="io-conv-list" style="flex: 0 0 auto;">
<div class="io-conv-item default active" data-default>
<div class="thumb">
<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>
</div>
<span class="nm">默认创作</span>
</div>
</div>
<div class="io-side-sec-h">最近</div>
<div class="io-conv-list" id="io-conv-list">
<!-- JS 渲染最近会话 -->
</div>
</aside>
<!-- ===== 右 · 对话流 ===== -->
<section class="io-main">
<div class="io-toolbar">
<span class="spacer"></span>
<button class="search-btn" type="button" title="搜索">
<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>
</button>
<button class="tb-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>
<button class="tb-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>
<button class="tb-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>
</div>
<div class="io-stream" id="io-stream">
<div class="io-stream-inner" id="io-stream-inner">
<!-- JS 渲染对话流 / 空态 -->
</div>
</div>
<button class="io-jump-bottom" type="button" id="io-jump-bottom">
回到底部
<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>
</button>
<!-- 底部输入栏 -->
<div class="io-input-wrap">
<div class="io-input">
<!-- 上行 · 参考图 + 加号按钮 (同一行, 64×64 同尺寸) -->
<div class="io-input-top">
<div class="io-input-refs" id="io-input-refs"></div>
<button class="add-btn" type="button" id="io-add-btn" 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>
</button>
<input type="file" id="io-file-input" accept="image/*" multiple>
</div>
<!-- 中行 · textarea 满宽 -->
<textarea id="io-input-text" rows="1" placeholder="输入想法、剧本或上传参考,支持 “/” 使用技能, @ 添加主体, 和 Agent 一起创作"></textarea>
<!-- 参数胶囊行 -->
<div class="io-input-bottom">
<div class="param" data-param="model" tabindex="0" hidden>
<span class="lbl-mono">模型</span>
<span id="io-param-model-lbl">Airshelf v2</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg>
<div class="io-param-menu" id="io-menu-model"></div>
</div>
<div class="param" data-param="ratio" tabindex="0">
<span class="lbl-mono">比例</span>
<span id="io-param-ratio-lbl">1:1</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg>
<div class="io-param-menu" id="io-menu-ratio"></div>
</div>
<div class="param" data-param="style" tabindex="0">
<span class="lbl-mono">风格</span>
<span id="io-param-style-lbl">默认</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg>
<div class="io-param-menu" id="io-menu-style"></div>
</div>
<div class="param" data-param="count" tabindex="0">
<span class="lbl-mono">张数</span>
<span id="io-param-count-lbl">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 class="io-param-menu" id="io-menu-count"></div>
</div>
<span class="right-meta">预估 <span class="val" id="io-cost-val">¥0.40</span> · 余额 <span class="val">¥327.40</span></span>
<button class="send-btn" type="button" id="io-send-btn" disabled title="生成">
<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>
</button>
</div>
</div>
</div>
</section>
</div>
</div>
<!-- ===== 重复加入资产库 · 确认弹窗 ===== -->
<div class="io-dup-modal-bg" id="io-dup-bg" hidden>
<div class="io-dup-modal">
<div class="dh">
<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="12" cy="12" r="9"/><path d="M12 8v4M12 16h.01"/></svg>
</div>
<div class="ti">
<strong id="io-dup-title">图片已在资产库</strong>
<span id="io-dup-sub" class="mono">// 选择处理方式</span>
</div>
</div>
<div class="df">
<button type="button" data-act="cancel">取消</button>
<button type="button" data-act="dup">新增副本</button>
<button type="button" class="primary" data-act="overwrite">覆盖原图</button>
</div>
</div>
</div>
<script src="assets/icons.js?v=2026052608"></script>
<script src="assets/shell.js?v=2026052607"></script>
<script>
Shell.render({
active: 'asset-factory',
crumbs: [
{ label: '工作台', href: 'index.html' },
{ label: '图片生成', href: 'asset-factory.html' },
{ label: '图片创作' }
]
});
</script>
<script>
/* ============================================================
图片创作 · 即梦风格 chat 工作台
----------------------------------------------------------
持久化:localStorage['fs-io-chat']
结构:[{ id, title, messages:[ {id, prompt, model, ratio, style, count,
refImages:[{id,name}], results:[{id,status,label}], createdAt} ] }]
============================================================ */
(function () {
'use strict';
const STORAGE_KEY = 'fs-io-chat';
const PRICE_PER = 0.10;
const MODELS = [
{ id: 'studio-v2', label: 'Airshelf v2', sub: '通用 · 速度优' },
{ id: 'studio-v2-pro', label: 'Airshelf v2 Pro', sub: '细节 · 商用' },
{ id: 'realistic', label: '写实增强', sub: '商品 · 人像' },
{ id: 'anime', label: '国风动漫', sub: '二次元 · 海报' },
];
const RATIOS = [
{ id: '1:1', label: '1:1', sub: '通用方图' },
{ id: '16:9', label: '16:9', sub: '横屏 · 横幅' },
{ id: '9:16', label: '9:16', sub: '竖屏 · 短视频' },
{ id: '4:3', label: '4:3', sub: '横向标准' },
{ id: '3:4', label: '3:4', sub: '纵向标准' },
];
const STYLES = [
{ id: 'auto', label: '默认' },
{ id: 'realistic', label: '写实' },
{ id: 'cinematic', label: '电影感' },
{ id: 'anime', label: '动漫' },
{ id: 'oil', label: '油画' },
{ id: 'cn-ink', label: '国风水墨' },
{ id: 'cyber', label: '赛博' },
];
const COUNTS = [
{ id: 1, label: '1' },
{ id: 2, label: '2' },
{ id: 4, label: '4' },
];
const EXAMPLES = [
'一只穿着宇航服的橘猫,漂浮在霓虹色星云中,赛博朋克风',
'极简北欧风格的茶杯,白底,自然柔光,产品摄影',
'国风水墨海报,主体一只白鹤立于水边,留白构图',
'电影感都市夜景,街道湿漉漉反射霓虹,4K 高清',
];
/* ---------- DOM ---------- */
const $ = sel => document.querySelector(sel);
const streamInner = $('#io-stream-inner');
const stream = $('#io-stream');
const inputText = $('#io-input-text');
const sendBtn = $('#io-send-btn');
const addBtn = $('#io-add-btn');
const fileInput = $('#io-file-input');
const inputRefs = $('#io-input-refs');
const costVal = $('#io-cost-val');
const convList = $('#io-conv-list');
const newConvBtn = $('#io-new-conv');
const jumpBtn = $('#io-jump-bottom');
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function uid() { return 'm-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5); }
function relTime(ts) {
const diff = (Date.now() - ts) / 1000;
if (diff < 60) return '刚刚';
if (diff < 3600) return Math.floor(diff / 60) + ' 分钟前';
if (diff < 86400) return Math.floor(diff / 3600) + ' 小时前';
if (diff < 86400 * 7) return Math.floor(diff / 86400) + ' 天前';
const d = new Date(ts), z = n => (n < 10 ? '0' + n : '' + n);
return d.getFullYear() + '-' + z(d.getMonth() + 1) + '-' + z(d.getDate());
}
/* ---------- state ---------- */
const state = {
convs: [], // 历史会话列表(元数据 id/title/thumb/updatedAt)
activeConvId: 'default',
messages: [], // 当前激活会话的对话流(从 convMessages 镜像出来)
convMessages: { default: [] }, // 所有会话的 messages 按 convId 持久化
refImages: [],
param: {
model: 'studio-v2',
ratio: '1:1',
style: 'auto',
count: 4,
},
};
function slimMessages(msgs) {
// dataUrl 体积大,持久化时剥离;只保留元数据
return (msgs || []).map(m => ({
...m,
refImages: (m.refImages || []).map(r => ({ id: r.id, name: r.name })),
}));
}
function loadAll() {
const fallback = { convs: [], activeConvId: 'default', convMessages: { default: [] } };
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return fallback;
const data = JSON.parse(raw) || {};
// 兼容旧结构 { convs, current: { id, messages } } → 迁移到 convMessages
if (!data.convMessages && data.current) {
data.convMessages = {};
data.convMessages[data.current.id || 'default'] = data.current.messages || [];
data.activeConvId = data.current.id || 'default';
delete data.current;
}
if (!data.convMessages) data.convMessages = { default: [] };
if (!data.convMessages.default) data.convMessages.default = [];
if (!Array.isArray(data.convs)) data.convs = [];
if (!data.activeConvId) data.activeConvId = 'default';
return data;
} catch (e) { return fallback; }
}
function saveAll() {
try {
// 当前对话内容同步进字典
state.convMessages[state.activeConvId] = slimMessages(state.messages);
const data = {
convs: state.convs.map(c => ({ id: c.id, title: c.title, thumb: c.thumb, updatedAt: c.updatedAt })),
activeConvId: state.activeConvId,
convMessages: state.convMessages,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch (e) { /* quota */ }
}
/* ---------- 任务中心 · 同步图片创作任务到共享 localStorage ---------- */
const IMG_TASK_KEY = 'fs-image-tasks-image';
function _loadImgTasks() {
try { return JSON.parse(localStorage.getItem(IMG_TASK_KEY) || '[]'); }
catch (e) { return []; }
}
function _saveImgTasks(arr) {
try { localStorage.setItem(IMG_TASK_KEY, JSON.stringify(arr)); } catch (e) {}
}
function _msgStatus(msg) {
if (!msg.results || !msg.results.length) return 'gen';
if (msg.results.some(r => r.status === 'gen')) return 'gen';
if (msg.results.every(r => r.status === 'err')) return 'err';
return 'ok';
}
function _timeNow(ts) {
const d = new Date(ts || Date.now());
return ('0'+(d.getMonth()+1)).slice(-2) + '.' + ('0'+d.getDate()).slice(-2)
+ ' ' + ('0'+d.getHours()).slice(-2) + ':' + ('0'+d.getMinutes()).slice(-2);
}
function syncImageTask(msg) {
if (!msg || !msg.id) return;
const arr = _loadImgTasks();
const promptShort = (msg.prompt || '').trim().replace(/\s+/g, ' ').slice(0, 24);
const ratio = msg.ratio || '';
const taskRec = {
id: 'img-' + msg.id,
type: 'image',
name: '图片创作 · ' + (promptShort || '未命名 prompt'),
snap: {
prompt: msg.prompt,
count: msg.count || (msg.results ? msg.results.length : 1),
ratio: ratio, style: msg.style, model: msg.model,
},
status: _msgStatus(msg),
time: _timeNow(msg.createdAt),
createdAt: msg.createdAt || Date.now(),
};
const idx = arr.findIndex(t => t.id === taskRec.id);
if (idx >= 0) arr[idx] = taskRec;
else arr.unshift(taskRec);
// 限制最多保留 200 条
if (arr.length > 200) arr.length = 200;
_saveImgTasks(arr);
}
/* ---------- 渲染:左侧会话列表 ---------- */
function renderSide() {
document.querySelectorAll('.io-conv-item.default').forEach(d =>
d.classList.toggle('active', state.activeConvId === 'default'));
if (!state.convs.length) {
convList.innerHTML = `<div style="padding:14px 12px;font-size:11.5px;color:var(--black-alpha-48);line-height:1.55;">
还没有最近会话<br><span style="font-family:var(--font-mono);font-size:10.5px;letter-spacing:.02em;display:inline-block;margin-top:4px">// NO HISTORY</span>
</div>`;
return;
}
convList.innerHTML = state.convs.map(c => `
<div class="io-conv-item${state.activeConvId === c.id ? ' active' : ''}" data-id="${c.id}">
<div class="thumb"${c.thumb ? ` style="background-image:url(${c.thumb})"` : ''}>
${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>'}
</div>
<span class="nm">${esc(c.title)}</span>
<button class="del" type="button" data-del="${c.id}" title="删除">
<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>
</div>
`).join('');
convList.querySelectorAll('.io-conv-item').forEach(it => {
it.addEventListener('click', e => {
if (e.target.closest('[data-del]')) return;
switchConv(it.dataset.id);
});
});
convList.querySelectorAll('[data-del]').forEach(b => {
b.addEventListener('click', e => {
e.stopPropagation();
deleteConv(b.dataset.del);
});
});
}
document.querySelectorAll('.io-conv-item.default').forEach(d => {
d.addEventListener('click', () => switchConv('default'));
});
function switchConv(id) {
if (id === state.activeConvId) return;
// 先把当前会话内容写回字典(slim),再切换
state.convMessages[state.activeConvId] = slimMessages(state.messages);
state.activeConvId = id;
// 从字典读出目标会话的 messages(没有就空)
state.messages = (state.convMessages[id] || []).slice();
saveAll();
renderSide();
renderStream();
setTimeout(() => stream.scrollTo({ top: stream.scrollHeight }), 30);
}
function deleteConv(id) {
state.convs = state.convs.filter(c => c.id !== id);
delete state.convMessages[id];
if (state.activeConvId === id) {
state.activeConvId = 'default';
state.messages = (state.convMessages['default'] || []).slice();
}
saveAll();
renderSide();
renderStream();
}
/* ---------- 渲染:中央对话流 ---------- */
function renderStream() {
if (!state.messages.length) {
streamInner.innerHTML = `
<div class="io-empty">
<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="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>
</div>
<div class="badge">// IMAGE STUDIO</div>
<h2>开始你的创作</h2>
<p>描述你想要的画面,AI 会按提示词 + 参考图 + 模型偏好,生成符合电商场景的视觉素材。<br>支持详情图、海报、灵感速写、3D 化等多场景。</p>
<div class="examples">
${EXAMPLES.map(e => `<button class="ex" type="button" data-ex="${esc(e)}">${esc(e)}</button>`).join('')}
</div>
</div>`;
// 示例点击
streamInner.querySelectorAll('.ex').forEach(b => {
b.addEventListener('click', () => {
inputText.value = b.dataset.ex;
syncSendDisabled();
inputText.focus();
autoResize();
});
});
return;
}
streamInner.innerHTML = state.messages.map(messageHTML).join('');
bindMessageEvents();
}
function rClass(r) { return 'r-' + r.replace(':', '-'); }
function modelLabel(id) { const m = MODELS.find(x => x.id === id); return m ? m.label : id; }
function styleLabel(id) { const s = STYLES.find(x => x.id === id); return s ? s.label : id; }
function messageHTML(m) {
const cellsHTML = m.results.map(r => `
<div class="io-cell ${rClass(m.ratio)} ${r.status}" data-msg-id="${m.id}" data-cell-id="${r.id}">
<div class="ph-frame">${r.status === 'gen' ? '生成中…' : (r.status === 'err' ? '失败 · 点重新生成' : esc(r.label))}</div>
${r.status === 'ok' ? `
<div class="cell-ops">
<button type="button" data-act="cell-rerun" 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>
<button type="button" data-act="dl" 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>
<div class="cell-more-wrap">
<button type="button" data-act="more" title="更多">
<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>
</button>
<div class="cell-more-menu">
<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>
<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>
</div>
</div>
</div>
` : ''}
</div>
`).join('');
const tagsHTML = `
<span class="meta-chip">${esc(modelLabel(m.model))}</span>
<span class="sep">|</span>
<span>${esc(m.ratio)}</span>
<span class="sep">|</span>
<span>1K</span>
${m.style && m.style !== 'auto' ? `<span class="sep">|</span><span>${esc(styleLabel(m.style))}</span>` : ''}
<span class="sep">·</span>
<span>${esc(relTime(m.createdAt))}</span>
`;
return `<div class="io-msg" data-id="${m.id}">
<div class="io-msg-prompt">
<div class="quote">
<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>
</div>
<div class="pt">
<div class="pt-text">${esc(m.prompt)}</div>
<div class="pt-tags">${tagsHTML}</div>
</div>
</div>
<div class="io-msg-grid">${cellsHTML}</div>
<div class="io-msg-ops">
<button type="button" data-act="edit" data-id="${m.id}">
<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>
重新编辑
</button>
<button type="button" data-act="rerun" data-id="${m.id}">
<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>
<div class="msg-more-wrap">
<button type="button" class="icon" data-act="msg-more" data-id="${m.id}" title="更多">
<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>
</button>
<div class="msg-more-menu" role="menu">
<button type="button" data-act="msg-save-all" data-id="${m.id}">
<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>
全部加入资产库
</button>
<button type="button" class="danger" data-act="msg-del" data-id="${m.id}">
<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>
</div>
</div>
</div>
</div>`;
}
function bindMessageEvents() {
streamInner.querySelectorAll('[data-act="edit"]').forEach(b => {
b.addEventListener('click', () => editMessage(b.dataset.id));
});
streamInner.querySelectorAll('[data-act="rerun"]').forEach(b => {
b.addEventListener('click', () => rerunMessage(b.dataset.id));
});
// 更多按钮 · 切换批次 menu
streamInner.querySelectorAll('[data-act="msg-more"]').forEach(b => {
b.addEventListener('click', e => {
e.stopPropagation();
const wrap = b.closest('.msg-more-wrap');
const isOpen = wrap.classList.contains('open');
document.querySelectorAll('.msg-more-wrap.open').forEach(w => w.classList.remove('open'));
if (!isOpen) wrap.classList.add('open');
});
});
// 删除该批结果
streamInner.querySelectorAll('[data-act="msg-del"]').forEach(b => {
b.addEventListener('click', e => {
e.stopPropagation();
const wrap = b.closest('.msg-more-wrap');
if (wrap) wrap.classList.remove('open');
state.messages = state.messages.filter(m => m.id !== b.dataset.id);
saveAll(); renderStream();
if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('已删除该批结果');
});
});
// 全部加入资产库
streamInner.querySelectorAll('[data-act="msg-save-all"]').forEach(b => {
b.addEventListener('click', e => {
e.stopPropagation();
const wrap = b.closest('.msg-more-wrap');
if (wrap) wrap.classList.remove('open');
saveBatchToLibrary(b.dataset.id);
});
});
streamInner.querySelectorAll('[data-act="dl"]').forEach(b => {
b.addEventListener('click', e => {
e.stopPropagation();
if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('已加入下载', '占位 · mock 演示');
});
});
// 更多 → 切换菜单
streamInner.querySelectorAll('[data-act="more"]').forEach(b => {
b.addEventListener('click', e => {
e.stopPropagation();
const wrap = b.closest('.cell-more-wrap');
const isOpen = wrap.classList.contains('open');
// 关闭其它所有 menu
document.querySelectorAll('.cell-more-wrap.open').forEach(w => w.classList.remove('open'));
if (!isOpen) wrap.classList.add('open');
});
});
// 加入资产库
streamInner.querySelectorAll('[data-act="save-lib"]').forEach(b => {
b.addEventListener('click', e => {
e.stopPropagation();
const cell = b.closest('.io-cell');
const wrap = b.closest('.cell-more-wrap');
if (wrap) wrap.classList.remove('open');
saveToLibrary(cell.dataset.msgId, cell.dataset.cellId);
});
});
// 单张删除
streamInner.querySelectorAll('[data-act="cell-del"]').forEach(b => {
b.addEventListener('click', e => {
e.stopPropagation();
const cell = b.closest('.io-cell');
const wrap = b.closest('.cell-more-wrap');
if (wrap) wrap.classList.remove('open');
deleteCell(cell.dataset.msgId, cell.dataset.cellId);
});
});
// 单张再次生成
streamInner.querySelectorAll('[data-act="cell-rerun"]').forEach(b => {
b.addEventListener('click', e => {
e.stopPropagation();
const cell = b.closest('.io-cell');
rerunCell(cell.dataset.msgId, cell.dataset.cellId);
});
});
}
/* ---------- 单张再次生成 ---------- */
function rerunCell(msgId, cellId) {
const m = state.messages.find(x => x.id === msgId);
if (!m) return;
const r = (m.results || []).find(x => x.id === cellId);
if (!r) return;
r.status = 'gen';
saveAll();
renderStream();
setTimeout(() => {
r.status = Math.random() < 0.06 ? 'err' : 'ok';
saveAll();
renderStream();
syncImageTask(m);
}, 1100 + Math.random() * 800);
if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('已重跑', '该图重新生成中');
}
/* ---------- 单张删除 / 加入资产库 ---------- */
function deleteCell(msgId, cellId) {
const m = state.messages.find(x => x.id === msgId);
if (!m) return;
m.results = (m.results || []).filter(r => r.id !== cellId);
// 如果该 msg 下没有 result 了,顺手删掉整条
if (!m.results.length) state.messages = state.messages.filter(x => x.id !== msgId);
saveAll();
renderStream();
if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('已删除');
}
/* ---------- 资产库 IO ---------- */
const LIB_KEY = 'fs-library-unclassified';
function readLib() {
let list;
try { list = JSON.parse(localStorage.getItem(LIB_KEY) || '[]'); } catch (e) { list = []; }
return Array.isArray(list) ? list : [];
}
function writeLib(list) {
try { localStorage.setItem(LIB_KEY, JSON.stringify(list)); } catch (e) {}
}
function buildLibEntry(m, cellId) {
return {
id: 'lib-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5),
cellId: cellId,
name: (m.prompt || '未命名').slice(0, 18),
prompt: m.prompt || '',
ratio: m.ratio,
model: m.model,
style: m.style,
source: '图片创作',
kind: '未分类',
addedAt: Date.now(),
};
}
/* ---------- 重复确认弹窗 ---------- */
function confirmDup({ count, isBatch }) {
return new Promise(resolve => {
const bg = document.getElementById('io-dup-bg');
document.getElementById('io-dup-title').textContent = isBatch
? `${count} 张已在资产库`
: '该图已在资产库';
document.getElementById('io-dup-sub').textContent = isBatch
? '// 新增副本 = 各自独立 · 覆盖 = 更新时间到顶'
: '// 新增副本 = 多一份独立条目 · 覆盖 = 更新时间到顶';
bg.hidden = false;
const buttons = bg.querySelectorAll('button[data-act]');
function done(choice) {
bg.hidden = true;
buttons.forEach(b => b.onclick = null);
bg.onclick = null;
document.removeEventListener('keydown', escHandler);
resolve(choice);
}
function escHandler(e) { if (e.key === 'Escape') done('cancel'); }
buttons.forEach(b => b.onclick = () => done(b.dataset.act));
bg.onclick = e => { if (e.target === bg) done('cancel'); };
document.addEventListener('keydown', escHandler);
});
}
/* ---------- 单张加入资产库 ---------- */
async function saveToLibrary(msgId, cellId) {
const m = state.messages.find(x => x.id === msgId);
if (!m) return;
const r = (m.results || []).find(x => x.id === cellId);
if (!r) return;
const list = readLib();
const dupIdx = list.findIndex(x => x.cellId === cellId);
if (dupIdx >= 0) {
const choice = await confirmDup({ count: 1, isBatch: false });
if (choice === 'cancel') return;
if (choice === 'overwrite') {
// 移除旧的,把新的放到最前
const [old] = list.splice(dupIdx, 1);
list.unshift({ ...old, addedAt: Date.now(), prompt: m.prompt || old.prompt, ratio: m.ratio, model: m.model, style: m.style });
writeLib(list);
if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('已覆盖原图', '资产库 → 未分类');
return;
}
// dup: 新增副本 — 不去重,继续走 unshift
}
list.unshift(buildLibEntry(m, cellId));
writeLib(list);
if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('已加入资产库', '+ 1 张 · 资产库 → 未分类');
}
/* ---------- 整批加入资产库 ---------- */
async function saveBatchToLibrary(msgId) {
const m = state.messages.find(x => x.id === msgId);
if (!m || !m.results || !m.results.length) {
if (typeof Shell !== 'undefined' && Shell.toast) Shell.toast('无可加入的结果', '该批次为空');
return;
}
const list = readLib();
const existing = new Set(list.map(x => x.cellId));
const dupCells = m.results.filter(r => existing.has(r.id));
const newCells = m.results.filter(r => !existing.has(r.id));
let dupAction = 'skip'; // 默认仅加入新的
if (dupCells.length > 0) {
const choice = await confirmDup({ count: dupCells.length, isBatch: true });
if (choice === 'cancel') return;
dupAction = choice; // 'dup' = 新增副本 / 'overwrite' = 覆盖
}
let added = 0, overwritten = 0;
// 先处理新增的 cell
newCells.forEach(r => {
list.unshift(buildLibEntry(m, r.id));
added++;
});
// 处理重复的
if (dupAction === 'dup') {
dupCells.forEach(r => { list.unshift(buildLibEntry(m, r.id)); added++; });
} else if (dupAction === 'overwrite') {
dupCells.forEach(r => {
const idx = list.findIndex(x => x.cellId === r.id);
if (idx >= 0) {
const [old] = list.splice(idx, 1);
list.unshift({ ...old, addedAt: Date.now(), prompt: m.prompt || old.prompt, ratio: m.ratio, model: m.model, style: m.style });
overwritten++;
}
});
}
writeLib(list);
if (typeof Shell !== 'undefined' && Shell.toast) {
const parts = [];
if (added > 0) parts.push(`+ ${added}`);
if (overwritten > 0) parts.push(`覆盖 ${overwritten}`);
Shell.toast(added > 0 || overwritten > 0 ? '已加入资产库' : '已取消', parts.length ? parts.join(' · ') + ' · 资产库 → 未分类' : '无变更');
}
}
function editMessage(id) {
const m = state.messages.find(x => x.id === id);
if (!m) return;
inputText.value = m.prompt;
state.param = { model: m.model, ratio: m.ratio, style: m.style, count: m.count };
syncParamLabels();
syncSendDisabled();
autoResize();
inputText.focus();
stream.scrollTo({ top: stream.scrollHeight, behavior: 'smooth' });
}
function rerunMessage(id) {
const m = state.messages.find(x => x.id === id);
if (!m) return;
const newMsg = createMessage(m.prompt, { model: m.model, ratio: m.ratio, style: m.style, count: m.count, refImages: m.refImages || [] });
state.messages.push(newMsg);
saveAll();
renderStream();
scheduleResults(newMsg);
setTimeout(() => stream.scrollTo({ top: stream.scrollHeight, behavior: 'smooth' }), 30);
}
/* ---------- 生成 ---------- */
function createMessage(prompt, opts) {
const p = opts || {};
const count = p.count || state.param.count;
return {
id: uid(),
prompt: prompt,
model: p.model || state.param.model,
ratio: p.ratio || state.param.ratio,
style: p.style || state.param.style,
count: count,
refImages: p.refImages || state.refImages.slice(),
results: Array.from({ length: count }, (_, i) => ({
id: 'r-' + uid(), status: 'gen', label: (p.ratio || state.param.ratio) + ' · #' + (i + 1),
})),
createdAt: Date.now(),
};
}
function scheduleResults(msg) {
msg.results.forEach((r, idx) => {
setTimeout(() => {
r.status = Math.random() < 0.06 ? 'err' : 'ok';
saveAll();
renderStream();
syncImageTask(msg);
}, 1100 + idx * 300 + Math.random() * 600);
});
}
function send() {
const txt = (inputText.value || '').trim();
if (!txt) return;
const msg = createMessage(txt);
state.messages.push(msg);
inputText.value = '';
state.refImages = [];
renderRefs();
syncSendDisabled();
autoResize();
saveAll();
renderStream();
scheduleResults(msg);
syncImageTask(msg);
setTimeout(() => stream.scrollTo({ top: stream.scrollHeight, behavior: 'smooth' }), 30);
}
/* ---------- 输入栏:参考图 ---------- */
function renderRefs() {
if (!state.refImages.length) {
inputRefs.classList.remove('show');
inputRefs.innerHTML = '';
return;
}
inputRefs.classList.add('show');
inputRefs.innerHTML = state.refImages.map(r => `
<div class="io-input-ref" data-id="${r.id}">
<img src="${r.dataUrl}" alt="">
<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>
</div>
`).join('');
inputRefs.querySelectorAll('.x').forEach(b => {
b.onclick = e => {
e.stopPropagation();
const id = b.closest('.io-input-ref').dataset.id;
state.refImages = state.refImages.filter(r => r.id !== id);
renderRefs();
};
});
}
addBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', e => {
const fs = [...e.target.files].filter(f => f.type.startsWith('image/'));
const room = 3 - state.refImages.length;
if (room <= 0) { e.target.value = ''; return; }
const incoming = fs.slice(0, room);
let done = 0;
incoming.forEach(f => {
const reader = new FileReader();
reader.onload = ev => {
state.refImages.push({ id: 'rf-' + uid(), dataUrl: ev.target.result, name: f.name });
if (++done === incoming.length) renderRefs();
};
reader.readAsDataURL(f);
});
e.target.value = '';
});
/* ---------- 输入框自适应高度 ---------- */
function autoResize() {
inputText.style.height = 'auto';
inputText.style.height = Math.min(180, inputText.scrollHeight) + 'px';
}
inputText.addEventListener('input', () => {
syncSendDisabled();
autoResize();
});
function syncSendDisabled() {
sendBtn.disabled = !(inputText.value || '').trim();
}
// 回车发送(Shift+Enter 换行)
inputText.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!sendBtn.disabled) send();
}
});
sendBtn.addEventListener('click', send);
/* ---------- 参数胶囊下拉 ---------- */
function buildParamMenus() {
function fill(menuEl, items, key, getLabel) {
menuEl.innerHTML = items.map(it => {
const v = it.id;
return `<div class="mi${state.param[key] === v ? ' selected' : ''}" data-val="${v}">
<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>${esc(it.label)}</span>
${it.sub ? `<span class="mi-sub">${esc(it.sub)}</span>` : ''}
</div>`;
}).join('');
menuEl.addEventListener('click', e => {
const mi = e.target.closest('.mi');
if (!mi) return;
e.stopPropagation();
const val = key === 'count' ? parseInt(mi.dataset.val, 10) : mi.dataset.val;
state.param[key] = val;
menuEl.parentElement.classList.remove('open');
syncParamLabels();
updateCost();
// 重新渲染当前菜单的 selected 状态
menuEl.querySelectorAll('.mi').forEach(x => x.classList.toggle('selected', x.dataset.val == String(val)));
});
}
fill($('#io-menu-model'), MODELS, 'model');
fill($('#io-menu-ratio'), RATIOS, 'ratio');
fill($('#io-menu-style'), STYLES, 'style');
fill($('#io-menu-count'), COUNTS, 'count');
}
function syncParamLabels() {
$('#io-param-model-lbl').textContent = (MODELS.find(m => m.id === state.param.model) || {}).label || state.param.model;
$('#io-param-ratio-lbl').textContent = state.param.ratio;
$('#io-param-style-lbl').textContent = (STYLES.find(s => s.id === state.param.style) || {}).label || state.param.style;
$('#io-param-count-lbl').textContent = state.param.count;
}
function updateCost() {
costVal.textContent = '¥' + (state.param.count * PRICE_PER).toFixed(2);
}
// 点击 chip 切换下拉
document.querySelectorAll('.io-input-bottom .param').forEach(p => {
p.addEventListener('click', e => {
// 菜单内 click 已 stop,这里只处理 chip 本体
if (e.target.closest('.io-param-menu')) return;
const isOpen = p.classList.contains('open');
document.querySelectorAll('.io-input-bottom .param.open').forEach(x => x.classList.remove('open'));
if (!isOpen) p.classList.add('open');
});
});
document.addEventListener('click', e => {
if (!e.target.closest('.io-input-bottom .param')) {
document.querySelectorAll('.io-input-bottom .param.open').forEach(x => x.classList.remove('open'));
}
if (!e.target.closest('.cell-more-wrap')) {
document.querySelectorAll('.cell-more-wrap.open').forEach(x => x.classList.remove('open'));
}
if (!e.target.closest('.msg-more-wrap')) {
document.querySelectorAll('.msg-more-wrap.open').forEach(x => x.classList.remove('open'));
}
});
/* ---------- 新对话 ---------- */
newConvBtn.addEventListener('click', () => {
// 若 default 上有内容,把它归档到「最近」(新 id + 转存 messages),然后清空 default
if (state.activeConvId === 'default' && state.messages.length) {
const first = state.messages[0];
const title = (first.prompt || '').slice(0, 18) || '未命名对话';
const newId = 'c-' + uid();
state.convMessages[newId] = slimMessages(state.messages);
state.convs.unshift({
id: newId,
title,
thumb: '',
updatedAt: Date.now(),
});
// 限制 20 条 + 同步清掉超额会话的 messages
if (state.convs.length > 20) {
const dropped = state.convs.slice(20);
state.convs = state.convs.slice(0, 20);
dropped.forEach(c => { delete state.convMessages[c.id]; });
}
}
// 清空 default 并切回 default
state.convMessages['default'] = [];
state.messages = [];
state.activeConvId = 'default';
saveAll();
renderSide();
renderStream();
inputText.focus();
});
/* ---------- 浮动"回到底部" ---------- */
stream.addEventListener('scroll', () => {
const near = stream.scrollTop + stream.clientHeight >= stream.scrollHeight - 120;
jumpBtn.classList.toggle('show', !near && state.messages.length > 0);
});
jumpBtn.addEventListener('click', () => {
stream.scrollTo({ top: stream.scrollHeight, behavior: 'smooth' });
});
/* ---------- 初始化 ---------- */
(function init() {
const data = loadAll();
state.convs = data.convs || [];
state.convMessages = data.convMessages || { default: [] };
state.activeConvId = data.activeConvId || 'default';
state.messages = (state.convMessages[state.activeConvId] || []).slice();
// URL ?prompt= 预填
try {
const q = new URLSearchParams(location.search);
const seed = q.get('prompt');
if (seed) inputText.value = decodeURIComponent(seed);
} catch (e) {}
buildParamMenus();
syncParamLabels();
updateCost();
renderRefs();
syncSendDisabled();
autoResize();
renderSide();
renderStream();
setTimeout(() => stream.scrollTo({ top: stream.scrollHeight }), 50);
})();
})();
</script>
</body>
</html>