1968 lines
76 KiB
HTML
1968 lines
76 KiB
HTML
<!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;
|
||
transition: grid-template-columns var(--t-base);
|
||
}
|
||
.io-app.side-collapsed { grid-template-columns: 0 1fr; }
|
||
@media (max-width: 1100px) {
|
||
.io-app { grid-template-columns: 200px 1fr; }
|
||
.io-app.side-collapsed { grid-template-columns: 0 1fr; }
|
||
}
|
||
|
||
/* ========== 左 · 会话栏 ========== */
|
||
.io-side {
|
||
border-right: 1px solid var(--border-faint);
|
||
background: var(--surface);
|
||
display: flex; flex-direction: column;
|
||
min-height: 0; overflow: hidden;
|
||
transition: opacity var(--t-base), transform var(--t-base);
|
||
}
|
||
.io-app.side-collapsed .io-side {
|
||
opacity: 0;
|
||
transform: translateX(-8px);
|
||
pointer-events: none;
|
||
}
|
||
.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: 34px; padding: 0 13px 0 11px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border-faint);
|
||
border-radius: var(--r-pill);
|
||
color: var(--accent-black);
|
||
font-size: 13px; 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(--black-alpha-4);
|
||
border-color: var(--black-alpha-24);
|
||
color: var(--accent-black);
|
||
}
|
||
.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 .side-restore-btn {
|
||
height: 32px;
|
||
padding: 0 10px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border-faint);
|
||
border-radius: var(--r-sm);
|
||
color: var(--black-alpha-72);
|
||
font-family: inherit;
|
||
font-size: 12.5px;
|
||
cursor: pointer;
|
||
}
|
||
.io-toolbar .side-restore-btn:hover { border-color: var(--heat-20); color: var(--heat); }
|
||
.io-toolbar .side-restore-btn[hidden] { display: none; }
|
||
.io-toolbar .side-restore-btn svg { width: 14px; height: 14px; }
|
||
.io-toolbar-search {
|
||
position: relative;
|
||
width: min(320px, 32vw);
|
||
min-width: 220px;
|
||
}
|
||
.io-toolbar-search[hidden] { display: none; }
|
||
.io-toolbar-search svg {
|
||
position: absolute;
|
||
left: 10px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
width: 13px;
|
||
height: 13px;
|
||
color: var(--black-alpha-48);
|
||
pointer-events: none;
|
||
}
|
||
.io-toolbar-search input {
|
||
width: 100%;
|
||
height: 32px;
|
||
padding: 0 32px 0 30px;
|
||
background: var(--background-lighter);
|
||
border: 1px solid var(--border-faint);
|
||
border-radius: var(--r-sm);
|
||
color: var(--accent-black);
|
||
font-family: inherit;
|
||
font-size: 12.5px;
|
||
outline: none;
|
||
}
|
||
.io-toolbar-search input:focus { border-color: var(--heat-40); background: var(--surface); }
|
||
.io-toolbar-search .clear-search {
|
||
position: absolute;
|
||
right: 4px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
width: 24px;
|
||
height: 24px;
|
||
border: 0;
|
||
border-radius: var(--r-sm);
|
||
background: transparent;
|
||
color: var(--black-alpha-48);
|
||
cursor: pointer;
|
||
display: grid;
|
||
place-items: center;
|
||
}
|
||
.io-toolbar-search .clear-search:hover { background: var(--black-alpha-4); color: var(--accent-black); }
|
||
.io-toolbar-search .clear-search svg {
|
||
position: static;
|
||
transform: none;
|
||
width: 12px;
|
||
height: 12px;
|
||
}
|
||
.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.active { background: var(--heat-12); border-color: var(--heat-20); color: var(--heat); }
|
||
.io-toolbar .search-btn svg { width: 14px; height: 14px; }
|
||
.io-tool-filter { position: relative; display: inline-flex; }
|
||
.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.active { background: var(--heat-12); border-color: var(--heat-20); color: var(--heat); }
|
||
.io-toolbar .tb-chip svg { width: 10px; height: 10px; opacity: .6; }
|
||
.io-tool-filter.open .tb-chip svg { transform: rotate(180deg); }
|
||
.io-tool-menu {
|
||
position: absolute;
|
||
top: calc(100% + 4px);
|
||
right: 0;
|
||
min-width: 156px;
|
||
display: none;
|
||
padding: 4px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border-faint);
|
||
border-radius: var(--r-md);
|
||
box-shadow: var(--shadow-floating);
|
||
z-index: 30;
|
||
}
|
||
.io-tool-filter.open .io-tool-menu { display: block; }
|
||
.io-tool-menu .mi {
|
||
width: 100%;
|
||
min-height: 32px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 0 10px;
|
||
border: 0;
|
||
border-radius: var(--r-sm);
|
||
background: transparent;
|
||
color: var(--accent-black);
|
||
font-family: inherit;
|
||
font-size: 12.5px;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
}
|
||
.io-tool-menu .mi:hover { background: var(--black-alpha-4); }
|
||
.io-tool-menu .mi.selected { background: var(--heat-12); color: var(--heat); font-weight: 500; }
|
||
.io-tool-menu .mi svg {
|
||
width: 12px;
|
||
height: 12px;
|
||
opacity: 0;
|
||
color: var(--heat);
|
||
}
|
||
.io-tool-menu .mi.selected svg { opacity: 1; }
|
||
|
||
/* 对话流主体 */
|
||
.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">
|
||
<button class="side-restore-btn" type="button" id="io-side-restore" hidden 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>
|
||
<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>
|
||
<div class="io-toolbar-search" id="io-toolbar-search" hidden>
|
||
<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>
|
||
<input id="io-toolbar-search-input" type="text" placeholder="搜索当前对话结果">
|
||
<button class="clear-search" type="button" id="io-toolbar-search-clear" title="清空搜索">
|
||
<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>
|
||
</button>
|
||
</div>
|
||
<div class="io-tool-filter" data-filter="time">
|
||
<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>
|
||
<div class="io-tool-menu" data-filter-menu></div>
|
||
</div>
|
||
<div class="io-tool-filter" data-filter="mode">
|
||
<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>
|
||
<div class="io-tool-menu" data-filter-menu></div>
|
||
</div>
|
||
<div class="io-tool-filter" data-filter="action">
|
||
<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>
|
||
<div class="io-tool-menu" data-filter-menu></div>
|
||
</div>
|
||
</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');
|
||
const ioApp = $('.io-app');
|
||
const foldBtn = $('.io-side-h .fold');
|
||
const sideRestore = $('#io-side-restore');
|
||
const toolbarSearchBtn = $('.io-toolbar .search-btn');
|
||
const toolbarSearch = $('#io-toolbar-search');
|
||
const toolbarSearchInput = $('#io-toolbar-search-input');
|
||
const toolbarSearchClear = $('#io-toolbar-search-clear');
|
||
|
||
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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: [],
|
||
toolbar: {
|
||
q: '',
|
||
time: 'all',
|
||
mode: 'all',
|
||
action: 'all',
|
||
},
|
||
param: {
|
||
model: 'studio-v2',
|
||
ratio: '1:1',
|
||
style: 'auto',
|
||
count: 4,
|
||
},
|
||
};
|
||
|
||
const TOOL_FILTERS = {
|
||
time: [
|
||
{ id: 'all', label: '时间' },
|
||
{ id: 'today', label: '今天' },
|
||
{ id: 'week', label: '近 7 天' },
|
||
],
|
||
mode: [
|
||
{ id: 'all', label: '生成模式' },
|
||
{ id: 'studio-v2', label: 'Airshelf v2' },
|
||
{ id: 'studio-v2-pro', label: 'v2 Pro' },
|
||
{ id: 'realistic', label: '写实增强' },
|
||
{ id: 'anime', label: '国风动漫' },
|
||
],
|
||
action: [
|
||
{ id: 'all', label: '操作类型' },
|
||
{ id: 'ok', label: '已完成' },
|
||
{ id: 'gen', label: '生成中' },
|
||
{ id: 'err', label: '失败' },
|
||
],
|
||
};
|
||
|
||
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 getVisibleMessages() {
|
||
const t = state.toolbar;
|
||
const q = (t.q || '').trim().toLowerCase();
|
||
const now = Date.now();
|
||
return state.messages.filter(m => {
|
||
if (q) {
|
||
const hay = [
|
||
m.prompt,
|
||
m.ratio,
|
||
modelLabel(m.model),
|
||
styleLabel(m.style),
|
||
...(m.results || []).map(r => r.label || r.status),
|
||
].join(' ').toLowerCase();
|
||
if (!hay.includes(q)) return false;
|
||
}
|
||
if (t.time === 'today' && now - (m.createdAt || now) > 86400000) return false;
|
||
if (t.time === 'week' && now - (m.createdAt || now) > 86400000 * 7) return false;
|
||
if (t.mode !== 'all' && m.model !== t.mode) return false;
|
||
if (t.action !== 'all' && !(m.results || []).some(r => r.status === t.action)) return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
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;
|
||
}
|
||
const visible = getVisibleMessages();
|
||
if (!visible.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"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
||
</div>
|
||
<div class="badge">// NO MATCH</div>
|
||
<h2>没有符合筛选的结果</h2>
|
||
<p>当前对话里没有匹配的批次,可以清空搜索或切回全部筛选。</p>
|
||
<div class="examples"><button class="ex" type="button" id="io-clear-toolbar-filters">清空筛选</button></div>
|
||
</div>`;
|
||
$('#io-clear-toolbar-filters')?.addEventListener('click', () => {
|
||
state.toolbar = { q: '', time: 'all', mode: 'all', action: 'all' };
|
||
if (toolbarSearchInput) toolbarSearchInput.value = '';
|
||
syncToolbarFilters();
|
||
renderStream();
|
||
});
|
||
return;
|
||
}
|
||
streamInner.innerHTML = visible.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('.io-tool-filter')) {
|
||
document.querySelectorAll('.io-tool-filter.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'));
|
||
}
|
||
});
|
||
|
||
/* ---------- 顶部工具栏:折叠 / 搜索 / 筛选 ---------- */
|
||
function setSideCollapsed(collapsed) {
|
||
ioApp?.classList.toggle('side-collapsed', collapsed);
|
||
if (sideRestore) sideRestore.hidden = !collapsed;
|
||
try { localStorage.setItem('fs-io-side-collapsed', collapsed ? '1' : '0'); } catch (e) {}
|
||
}
|
||
foldBtn?.addEventListener('click', () => setSideCollapsed(true));
|
||
sideRestore?.addEventListener('click', () => setSideCollapsed(false));
|
||
|
||
toolbarSearchBtn?.addEventListener('click', () => {
|
||
const open = toolbarSearch?.hidden;
|
||
if (toolbarSearch) toolbarSearch.hidden = !open;
|
||
toolbarSearchBtn.classList.toggle('active', !!open || !!state.toolbar.q);
|
||
if (open) requestAnimationFrame(() => toolbarSearchInput?.focus());
|
||
});
|
||
toolbarSearchInput?.addEventListener('input', e => {
|
||
state.toolbar.q = e.target.value.trim();
|
||
toolbarSearchBtn?.classList.toggle('active', !!state.toolbar.q || !toolbarSearch?.hidden);
|
||
renderStream();
|
||
});
|
||
toolbarSearchClear?.addEventListener('click', () => {
|
||
state.toolbar.q = '';
|
||
if (toolbarSearchInput) toolbarSearchInput.value = '';
|
||
toolbarSearchBtn?.classList.toggle('active', !toolbarSearch?.hidden);
|
||
renderStream();
|
||
toolbarSearchInput?.focus();
|
||
});
|
||
|
||
function syncToolbarFilters() {
|
||
document.querySelectorAll('.io-tool-filter').forEach(wrap => {
|
||
const key = wrap.dataset.filter;
|
||
const value = state.toolbar[key] || 'all';
|
||
const items = TOOL_FILTERS[key] || [];
|
||
const selected = items.find(x => x.id === value) || items[0];
|
||
wrap.querySelector('[data-filter-label]').textContent = selected?.label || '';
|
||
wrap.querySelector('.tb-chip')?.classList.toggle('active', value !== 'all');
|
||
const menu = wrap.querySelector('[data-filter-menu]');
|
||
if (!menu) return;
|
||
menu.innerHTML = items.map(it => `
|
||
<button class="mi${it.id === value ? ' selected' : ''}" type="button" data-val="${esc(it.id)}">
|
||
<svg 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>
|
||
</button>
|
||
`).join('');
|
||
});
|
||
}
|
||
|
||
document.querySelectorAll('.io-tool-filter').forEach(wrap => {
|
||
const btn = wrap.querySelector('.tb-chip');
|
||
const menu = wrap.querySelector('[data-filter-menu]');
|
||
btn?.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
const open = wrap.classList.contains('open');
|
||
document.querySelectorAll('.io-tool-filter.open').forEach(x => x.classList.remove('open'));
|
||
if (!open) wrap.classList.add('open');
|
||
});
|
||
menu?.addEventListener('click', e => {
|
||
const item = e.target.closest('.mi');
|
||
if (!item) return;
|
||
e.stopPropagation();
|
||
state.toolbar[wrap.dataset.filter] = item.dataset.val;
|
||
wrap.classList.remove('open');
|
||
syncToolbarFilters();
|
||
renderStream();
|
||
});
|
||
});
|
||
|
||
/* ---------- 新对话 ---------- */
|
||
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();
|
||
syncToolbarFilters();
|
||
try { setSideCollapsed(localStorage.getItem('fs-io-side-collapsed') === '1'); } catch (e) {}
|
||
updateCost();
|
||
renderRefs();
|
||
syncSendDisabled();
|
||
autoResize();
|
||
renderSide();
|
||
renderStream();
|
||
setTimeout(() => stream.scrollTo({ top: stream.scrollHeight }), 50);
|
||
})();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|