feat(core/frontend): P1 pixel restoration (settings/messages/wizard/product-create faithful; ai-tools draft)
- settings.tsx + settings-page.css: restore to settings.html (left-nav + sections), real user/team data - messages.tsx + messages-page.css: rich inbox restore (filters/detail/props grid) on real notifications - projects.tsx ProjectWizardPage + project-wizard-page.css: restore to projects-new.html - products.tsx ProductCreateUploadPage + product-create-page.css: restore to product-create-v2 baseline - ai-tools.tsx (AssetFactory/ImageWorkbench) + ai-tools-page.css: DRAFT unified studio shell; deviates from per-page baselines (image-optimize should be chat-stream; model-photo product+person picker) -> pending rework alongside P3 standalone image-gen decision - shot-p1.mjs: playwright visual-parity capture (react vs exact baseline; uses 127.0.0.1 not localhost) - verified: tsc --noEmit clean; screenshots confirm settings/wizard/product-create/messages faithful Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
25bf3293df
commit
78fd7ee13d
538
core/frontend/src/ai-tools-page.css
Normal file
538
core/frontend/src/ai-tools-page.css
Normal file
@ -0,0 +1,538 @@
|
|||||||
|
/* AI 工具页 · 像素还原 scoped 样式
|
||||||
|
基线:public/exact/asset-factory.html(图片生成入口 + 任务中心)
|
||||||
|
public/exact/{model-photo,platform-cover,image-optimize}.html(工作室壳)
|
||||||
|
只用 design-restraint.css 的 token;共享组件类(.btn/.pill/.input/.field/
|
||||||
|
.placeholder/.with-corners/.toolbar/table.t/.tabs/.chip/.view-toggle…)直接复用,
|
||||||
|
不在此重写。以下仅页面专属结构。
|
||||||
|
挂载根:.asset-factory(AssetFactoryPage)/ .image-workbench(ImageWorkbenchPage)。 */
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
A · 图片生成入口(.asset-factory)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ─── 三 Hero 卡片网格(模特上身图 / 平台套图 / 图片创作 · 等比)─── */
|
||||||
|
.asset-factory .factory-hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 56px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1400px) {
|
||||||
|
.asset-factory .factory-hero { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.asset-factory .factory-hero { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-factory .factory-card {
|
||||||
|
position: relative;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 28px 30px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-factory .factory-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.asset-factory .factory-text { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
|
||||||
|
.asset-factory .factory-tag {
|
||||||
|
align-self: flex-start;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--black-alpha-48);
|
||||||
|
letter-spacing: .06em;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
background: var(--background-lighter);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.asset-factory .factory-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -.018em;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: var(--accent-black);
|
||||||
|
}
|
||||||
|
.asset-factory .factory-desc {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--black-alpha-64);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CTA 行:主按钮 + 价格 mono */
|
||||||
|
.asset-factory .factory-cta {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.asset-factory .factory-cta .cost {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--black-alpha-48);
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 任务中心 · section header(基线 .section-h 带 mono sub)─── */
|
||||||
|
.asset-factory .section-h { margin-top: 24px; }
|
||||||
|
.asset-factory .section-h .sub-mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--black-alpha-48);
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-factory .result-meta {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--black-alpha-48);
|
||||||
|
letter-spacing: .02em;
|
||||||
|
margin: 2px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 列表视图 · 表格容器(基线 #task-list-view)─── */
|
||||||
|
.asset-factory .task-list-view {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-muted);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.asset-factory .task-list-view table.t {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.asset-factory .task-list-view table.t thead th {
|
||||||
|
background: var(--background-lighter);
|
||||||
|
border-bottom-color: var(--border-muted);
|
||||||
|
}
|
||||||
|
.asset-factory .task-list-view table.t tbody td { border-bottom: 0; }
|
||||||
|
|
||||||
|
.asset-factory .task-name-cell { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.asset-factory .task-thumb { width: 40px; height: 40px; flex-shrink: 0; border-radius: var(--r-sm); }
|
||||||
|
.asset-factory .task-name { font-weight: 600; color: var(--accent-black); font-size: 13.5px; }
|
||||||
|
.asset-factory .task-sub {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--black-alpha-48);
|
||||||
|
margin-top: 3px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
.asset-factory .task-list-prog { display: flex; align-items: center; gap: 8px; min-width: 120px; }
|
||||||
|
.asset-factory .task-list-prog .bar {
|
||||||
|
flex: 1; height: 4px;
|
||||||
|
background: var(--black-alpha-7);
|
||||||
|
border-radius: 2px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.asset-factory .task-list-prog .bar span {
|
||||||
|
display: block; height: 100%;
|
||||||
|
background: var(--heat); border-radius: 2px;
|
||||||
|
animation: af-hp-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.asset-factory .task-list-prog .pct {
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px;
|
||||||
|
color: var(--heat); letter-spacing: .02em; white-space: nowrap;
|
||||||
|
}
|
||||||
|
@keyframes af-hp-pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scaleY(1); }
|
||||||
|
50% { opacity: .55; transform: scaleY(.7); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-factory .task-empty {
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--black-alpha-48);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.asset-factory .task-empty .mono {
|
||||||
|
font-family: var(--font-mono); font-size: 11px;
|
||||||
|
letter-spacing: .04em; margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
B · 图片工作室壳(.image-workbench)
|
||||||
|
基线:model-photo / platform-cover(商品空间 + 表单 + 预览)
|
||||||
|
与 image-optimize(对话流)布局相近 — 统一为 mode 感知工作室。
|
||||||
|
在 .content 内铺满可用高度(shell 由 App.tsx 渲染,这里只占正文)。
|
||||||
|
============================================================ */
|
||||||
|
.image-workbench {
|
||||||
|
/* 抵消 .content 的 48/28/72 padding,让工作室壳贴边铺满(同旧 .tool-shell 思路) */
|
||||||
|
margin: -48px -28px -72px;
|
||||||
|
min-height: calc(100vh - 64px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--background-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 顶栏 · toolbar 风格(返回 + 标题 + 右侧操作)─── */
|
||||||
|
.image-workbench .iw-topbar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex; align-items: center; gap: 14px;
|
||||||
|
padding: 12px 28px;
|
||||||
|
border-bottom: 1px solid var(--border-faint);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.image-workbench .iw-topbar .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);
|
||||||
|
}
|
||||||
|
.image-workbench .iw-topbar .back-pill:hover {
|
||||||
|
background: var(--black-alpha-4);
|
||||||
|
border-color: var(--black-alpha-24);
|
||||||
|
}
|
||||||
|
.image-workbench .iw-topbar .back-pill svg { width: 14px; height: 14px; }
|
||||||
|
.image-workbench .iw-topbar .iw-title { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||||
|
.image-workbench .iw-topbar .iw-title h1 {
|
||||||
|
font-size: 18px; font-weight: 600;
|
||||||
|
letter-spacing: -.01em; line-height: 1.2;
|
||||||
|
color: var(--accent-black);
|
||||||
|
}
|
||||||
|
.image-workbench .iw-topbar .iw-title .sub {
|
||||||
|
font-size: 12.5px; color: var(--black-alpha-56);
|
||||||
|
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.image-workbench .iw-topbar .iw-title .sub .mono {
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px;
|
||||||
|
color: var(--black-alpha-48); letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 三栏主体:商品空间(rail) + 参数表单 + 结果预览 ─── */
|
||||||
|
.image-workbench .iw-layout {
|
||||||
|
flex: 1; min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px 320px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.image-workbench .iw-layout { grid-template-columns: 240px 300px minmax(0, 1fr); }
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.image-workbench .iw-layout { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 最左 · 商品空间(单选) ── */
|
||||||
|
.image-workbench .iw-prod-space {
|
||||||
|
background: var(--surface);
|
||||||
|
border-right: 1px solid var(--border-faint);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
min-height: 0; overflow: hidden;
|
||||||
|
}
|
||||||
|
.image-workbench .iw-ps-h {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 14px 14px 10px;
|
||||||
|
}
|
||||||
|
.image-workbench .iw-ps-h .mono {
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px;
|
||||||
|
color: var(--black-alpha-48); letter-spacing: .06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.image-workbench .iw-ps-search {
|
||||||
|
position: relative; height: 32px;
|
||||||
|
margin: 0 14px 10px;
|
||||||
|
}
|
||||||
|
.image-workbench .iw-ps-search svg {
|
||||||
|
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
|
||||||
|
width: 13px; height: 13px;
|
||||||
|
color: var(--black-alpha-48); z-index: 2; pointer-events: none;
|
||||||
|
}
|
||||||
|
.image-workbench .iw-ps-search input {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
padding: 0 10px 0 30px;
|
||||||
|
background: var(--background-lighter);
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-size: 12.5px; color: var(--accent-black);
|
||||||
|
font-family: inherit; outline: none;
|
||||||
|
transition: border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.image-workbench .iw-ps-search input:focus { border-color: var(--heat-40); background: var(--surface); }
|
||||||
|
.image-workbench .iw-ps-search input::placeholder { color: var(--black-alpha-48); }
|
||||||
|
.image-workbench .iw-ps-list {
|
||||||
|
flex: 1; min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 10px 10px;
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
}
|
||||||
|
.image-workbench .iw-prod-item {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.image-workbench .iw-prod-item:hover { background: var(--black-alpha-4); }
|
||||||
|
.image-workbench .iw-prod-item.active { background: var(--heat-12); border-color: var(--heat-20); }
|
||||||
|
.image-workbench .iw-prod-item .thumb {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
}
|
||||||
|
.image-workbench .iw-prod-item .body { flex: 1; min-width: 0; }
|
||||||
|
.image-workbench .iw-prod-item .nm {
|
||||||
|
font-size: 12.5px; color: var(--accent-black); font-weight: 500;
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.image-workbench .iw-prod-item.active .nm { color: var(--heat); font-weight: 600; }
|
||||||
|
.image-workbench .iw-prod-item .sub {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-family: var(--font-mono); font-size: 10px;
|
||||||
|
color: var(--black-alpha-48); letter-spacing: .02em;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.image-workbench .iw-ps-empty {
|
||||||
|
padding: 24px 14px;
|
||||||
|
text-align: center;
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px;
|
||||||
|
color: var(--black-alpha-48); letter-spacing: .04em;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 中 · 参数表单 ── */
|
||||||
|
.image-workbench .iw-form {
|
||||||
|
border-right: 1px solid var(--border-faint);
|
||||||
|
background: var(--surface);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 18px 20px;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.image-workbench .iw-step { margin-bottom: 22px; }
|
||||||
|
.image-workbench .iw-step:last-of-type { margin-bottom: 0; }
|
||||||
|
.image-workbench .iw-step-h {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.image-workbench .iw-step-h .num {
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--heat-12); color: var(--heat);
|
||||||
|
font-family: var(--font-mono); font-size: 11px; font-weight: 700;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.image-workbench .iw-step-h .title { font-size: 14px; font-weight: 600; color: var(--accent-black); }
|
||||||
|
.image-workbench .iw-sub-h {
|
||||||
|
font-size: 12px; color: var(--black-alpha-48);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-family: var(--font-mono); letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
.image-workbench .iw-sub { margin-bottom: 12px; }
|
||||||
|
.image-workbench .iw-sub:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* 单选 pill row(比例 / 张数 / 风格) */
|
||||||
|
.image-workbench .pill-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.image-workbench .pill-row .opt {
|
||||||
|
flex: 1; min-width: 56px;
|
||||||
|
height: 32px;
|
||||||
|
background: var(--background-lighter);
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
color: var(--black-alpha-72);
|
||||||
|
font-size: 12.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.image-workbench .pill-row .opt:hover { color: var(--accent-black); }
|
||||||
|
.image-workbench .pill-row .opt.active {
|
||||||
|
background: var(--heat-12);
|
||||||
|
color: var(--heat);
|
||||||
|
border-color: var(--heat-40);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 模特 / 平台多选卡格 */
|
||||||
|
.image-workbench .iw-pick-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.image-workbench .iw-pick-grid.platforms { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.image-workbench .iw-pick-card {
|
||||||
|
position: relative;
|
||||||
|
background: var(--background-lighter);
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
text-align: left;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.image-workbench .iw-pick-card:hover { background: var(--surface); }
|
||||||
|
.image-workbench .iw-pick-card.selected { border-color: var(--heat); background: var(--heat-12); }
|
||||||
|
.image-workbench .iw-pick-card .m-thumb { aspect-ratio: 3/4; border-radius: var(--r-sm); }
|
||||||
|
.image-workbench .iw-pick-card.platforms-card { padding: 10px 6px; text-align: center; align-items: center; }
|
||||||
|
.image-workbench .iw-pick-card .m-name { font-size: 12.5px; font-weight: 500; color: var(--accent-black); }
|
||||||
|
.image-workbench .iw-pick-card.selected .m-name { color: var(--heat); }
|
||||||
|
.image-workbench .iw-pick-card .m-meta {
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px;
|
||||||
|
color: var(--black-alpha-48); letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
.image-workbench .iw-pick-card .m-check {
|
||||||
|
position: absolute; top: 10px; right: 10px;
|
||||||
|
width: 20px; height: 20px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1.5px solid var(--black-alpha-24);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
color: var(--accent-white); z-index: 2;
|
||||||
|
}
|
||||||
|
.image-workbench .iw-pick-card .m-check svg { width: 11px; height: 11px; opacity: 0; }
|
||||||
|
.image-workbench .iw-pick-card.selected .m-check { background: var(--heat); border-color: var(--heat); }
|
||||||
|
.image-workbench .iw-pick-card.selected .m-check svg { opacity: 1; }
|
||||||
|
|
||||||
|
/* 左栏底部 · 立即生成(主 CTA · 通栏) */
|
||||||
|
.image-workbench .iw-cta { margin-top: auto; padding-top: 14px; }
|
||||||
|
.image-workbench .iw-cta .btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
height: 44px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.image-workbench .iw-cta-hint {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-family: var(--font-mono); font-size: 11px;
|
||||||
|
color: var(--black-alpha-48); letter-spacing: .02em;
|
||||||
|
text-align: center; line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 右 · 结果预览 ── */
|
||||||
|
.image-workbench .iw-preview {
|
||||||
|
background: var(--background-base);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 18px 22px;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* prompt-style summary 卡片(引号 icon + 灰底 + 右上 meta) */
|
||||||
|
.image-workbench .iw-pv-h {
|
||||||
|
position: relative;
|
||||||
|
background: var(--background-lighter);
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 14px 18px 14px 44px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.image-workbench .iw-pv-h .quote-icon {
|
||||||
|
position: absolute; top: 13px; left: 16px;
|
||||||
|
width: 18px; height: 18px;
|
||||||
|
color: var(--black-alpha-24);
|
||||||
|
}
|
||||||
|
.image-workbench .iw-pv-h .pv-meta {
|
||||||
|
float: right;
|
||||||
|
font-family: var(--font-mono); font-size: 11px;
|
||||||
|
color: var(--black-alpha-48); letter-spacing: .04em;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.image-workbench .iw-pv-h .pv-meta b { color: var(--accent-black); font-weight: 600; }
|
||||||
|
.image-workbench .iw-pv-h .pv-line {
|
||||||
|
font-size: 13px; color: var(--accent-black);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 结果区:复用 §4.18 .gen-card 规范结构(scoped 实现 · 仅 token) */
|
||||||
|
.image-workbench .gen-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 16px;
|
||||||
|
display: flex; flex-direction: column; gap: 14px;
|
||||||
|
}
|
||||||
|
.image-workbench .gen-meta {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-family: var(--font-mono); font-size: 11.5px;
|
||||||
|
color: var(--black-alpha-48); letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
.image-workbench .gen-meta .m-sep { color: var(--black-alpha-24); }
|
||||||
|
.image-workbench .gen-images {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--cols, 4), 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1400px) {
|
||||||
|
.image-workbench .gen-images { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
.image-workbench .gen-image {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: var(--ratio, 1 / 1);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.image-workbench .gen-image .placeholder { position: absolute; inset: 0; }
|
||||||
|
/* 右上浮层按钮组(§4.18 .gen-image-actions) */
|
||||||
|
.image-workbench .gen-image-actions {
|
||||||
|
position: absolute; top: 8px; right: 8px;
|
||||||
|
display: flex; gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, .08);
|
||||||
|
opacity: 0; z-index: 2;
|
||||||
|
transition: opacity var(--t-base);
|
||||||
|
}
|
||||||
|
.image-workbench .gen-image:hover .gen-image-actions { opacity: 1; }
|
||||||
|
.image-workbench .gen-img-btn {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border: 0; border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--black-alpha-56);
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
transition: background var(--t-base), color var(--t-base);
|
||||||
|
}
|
||||||
|
.image-workbench .gen-img-btn:hover { background: var(--black-alpha-4); color: var(--accent-black); }
|
||||||
|
.image-workbench .gen-img-btn svg { width: 14px; height: 14px; }
|
||||||
|
/* 底部操作按钮行(§4.18 .gen-card-actions · 二级 + ghost · 不放主橙) */
|
||||||
|
.image-workbench .gen-card-actions {
|
||||||
|
display: flex; gap: 8px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 预览区空态 */
|
||||||
|
.image-workbench .iw-pv-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 24px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.image-workbench .iw-pv-empty .mono {
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px;
|
||||||
|
color: var(--black-alpha-48); letter-spacing: .06em;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.image-workbench .iw-pv-empty .title { font-size: 14px; font-weight: 600; color: var(--accent-black); }
|
||||||
|
.image-workbench .iw-pv-empty .hint {
|
||||||
|
font-size: 12.5px; color: var(--black-alpha-48);
|
||||||
|
line-height: 1.6; max-width: 320px;
|
||||||
|
}
|
||||||
|
.image-workbench .iw-pv-empty .hint b { color: var(--heat); font-weight: 600; }
|
||||||
@ -10,5 +10,7 @@ import "./pipeline-page.css";
|
|||||||
import "./projects-page.css";
|
import "./projects-page.css";
|
||||||
import "./products-page.css";
|
import "./products-page.css";
|
||||||
import "./library-page.css";
|
import "./library-page.css";
|
||||||
|
import "./messages-page.css";
|
||||||
|
import "./settings-page.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(<App />);
|
createRoot(document.getElementById("root")!).render(<App />);
|
||||||
|
|||||||
355
core/frontend/src/messages-page.css
Normal file
355
core/frontend/src/messages-page.css
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
/* messages-page.css · 对齐 public/exact/messages.html · 仅 token,scoped 在 .msg-* */
|
||||||
|
.msg-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.msg-page .page-head {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.msg-head-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.msg-workbench {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
|
||||||
|
min-height: 640px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.msg-panel {
|
||||||
|
min-width: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.msg-inbox,
|
||||||
|
.msg-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.msg-inbox { border-right: 1px solid var(--border-faint); }
|
||||||
|
.msg-panel-h {
|
||||||
|
min-height: 58px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-faint);
|
||||||
|
}
|
||||||
|
.msg-panel-h .ti {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-black);
|
||||||
|
}
|
||||||
|
.msg-panel-h .mono {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--black-alpha-48);
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
.msg-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--border-faint);
|
||||||
|
}
|
||||||
|
.msg-filter {
|
||||||
|
height: 30px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--black-alpha-56);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.msg-filter:hover {
|
||||||
|
border-color: var(--black-alpha-24);
|
||||||
|
color: var(--accent-black);
|
||||||
|
background: var(--black-alpha-4);
|
||||||
|
}
|
||||||
|
.msg-filter.active {
|
||||||
|
border-color: var(--heat-20);
|
||||||
|
background: var(--heat-12);
|
||||||
|
color: var(--heat);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.msg-filter .ct {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
.msg-search {
|
||||||
|
position: relative;
|
||||||
|
padding: 0 14px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-faint);
|
||||||
|
}
|
||||||
|
.msg-search svg {
|
||||||
|
position: absolute;
|
||||||
|
left: 26px;
|
||||||
|
top: 10px;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
color: var(--black-alpha-48);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.msg-search input {
|
||||||
|
width: 100%;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 12px 0 32px;
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--background-lighter);
|
||||||
|
color: var(--accent-black);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.msg-search input:focus {
|
||||||
|
background: var(--surface);
|
||||||
|
border-color: var(--heat-40);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--heat-40);
|
||||||
|
}
|
||||||
|
.msg-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.msg-item {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 30px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--border-faint);
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.msg-item:hover { background: var(--black-alpha-4); }
|
||||||
|
.msg-item.active { background: var(--heat-12); }
|
||||||
|
.msg-item.active::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--heat);
|
||||||
|
}
|
||||||
|
.msg-item.read .msg-item-title { color: var(--black-alpha-56); font-weight: 500; }
|
||||||
|
.msg-type-ic {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--background-lighter);
|
||||||
|
color: var(--black-alpha-72);
|
||||||
|
}
|
||||||
|
.msg-type-ic svg { width: 14px; height: 14px; }
|
||||||
|
.msg-type-ic.task { background: var(--heat-12); border-color: var(--heat-20); color: var(--heat); }
|
||||||
|
.msg-type-ic.team { background: var(--black-alpha-4); color: var(--accent-black); }
|
||||||
|
.msg-type-ic.billing { background: var(--honey-bg); border-color: var(--honey-bd); color: var(--accent-honey); }
|
||||||
|
.msg-type-ic.system { background: var(--black-alpha-7); color: var(--black-alpha-72); }
|
||||||
|
.msg-item-main { min-width: 0; }
|
||||||
|
.msg-item-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.msg-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--heat);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.msg-item.read .msg-dot { display: none; }
|
||||||
|
.msg-item-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--accent-black);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.msg-time {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--black-alpha-48);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
.msg-brief {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--black-alpha-56);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.msg-item-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.msg-priority {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--background-lighter);
|
||||||
|
color: var(--black-alpha-56);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
.msg-priority.ok { background: var(--forest-bg); border-color: var(--forest-bd); color: var(--accent-forest); }
|
||||||
|
.msg-priority.warn { background: var(--honey-bg); border-color: var(--honey-bd); color: var(--accent-honey); }
|
||||||
|
.msg-priority.err { background: var(--crimson-bg); border-color: var(--crimson-bd); color: var(--accent-crimson); }
|
||||||
|
.msg-priority.info { background: var(--heat-12); border-color: var(--heat-20); color: var(--heat); }
|
||||||
|
.msg-empty {
|
||||||
|
min-height: 320px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--black-alpha-48);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.msg-empty svg { width: 24px; height: 24px; color: var(--black-alpha-48); }
|
||||||
|
.msg-detail-empty {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 520px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--black-alpha-48);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.msg-detail-empty .ic {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--background-lighter);
|
||||||
|
}
|
||||||
|
.msg-detail-empty svg { width: 21px; height: 21px; }
|
||||||
|
.msg-detail-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 22px 24px 24px;
|
||||||
|
}
|
||||||
|
.msg-detail-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 18px;
|
||||||
|
border-bottom: 1px solid var(--border-faint);
|
||||||
|
}
|
||||||
|
.msg-detail-title {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.msg-detail-title h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.35;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -.012em;
|
||||||
|
color: var(--accent-black);
|
||||||
|
}
|
||||||
|
.msg-detail-title .meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--black-alpha-48);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
.msg-body-text {
|
||||||
|
margin: 18px 0 0;
|
||||||
|
color: var(--accent-black);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
.msg-props {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 110px 1fr;
|
||||||
|
gap: 10px 16px;
|
||||||
|
margin-top: 18px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--background-lighter);
|
||||||
|
}
|
||||||
|
.msg-props .k {
|
||||||
|
color: var(--black-alpha-48);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
.msg-props .v {
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--accent-black);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.msg-props .v a { color: var(--heat); }
|
||||||
|
.msg-detail-f {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-top: 1px solid var(--border-faint);
|
||||||
|
background: var(--background-lighter);
|
||||||
|
}
|
||||||
|
.msg-detail-f .spacer { flex: 1; }
|
||||||
|
.msg-foot-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--black-alpha-48);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
.msg-foot-note a { color: var(--heat); cursor: pointer; }
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.msg-workbench { grid-template-columns: minmax(300px, 340px) minmax(0, 1fr); }
|
||||||
|
}
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.msg-workbench { grid-template-columns: 1fr; }
|
||||||
|
.msg-inbox { border-right: 0; border-bottom: 1px solid var(--border-faint); }
|
||||||
|
.msg-list { max-height: 360px; }
|
||||||
|
}
|
||||||
267
core/frontend/src/product-create-page.css
Normal file
267
core/frontend/src/product-create-page.css
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
/* ============================================================
|
||||||
|
product-create-upload · 新建商品(上传原图 + 基本信息)
|
||||||
|
像素基线: public/exact/_archive/.../product-create-v2.html
|
||||||
|
只用 design-restraint.css 的 token · 共享类 (.page-head/.field/
|
||||||
|
.input/.select/.textarea/.btn/.bullet-list) 直接复用,本文件只放
|
||||||
|
该页专属布局(双栏卡片 / 原图槽位 / 提示框 / 吸底操作栏)。
|
||||||
|
全部规则 scope 在 .product-create-page 下,避免污染他页。
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ─── 主表单双栏 ─── */
|
||||||
|
.product-create-page .form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.05fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-create-page .form-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.product-create-page .form-card-wide { margin-bottom: 24px; }
|
||||||
|
|
||||||
|
.product-create-page .form-card .card-h {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.product-create-page .form-card .card-h h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-black);
|
||||||
|
}
|
||||||
|
.product-create-page .form-card .req-tag {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
background: var(--crimson-bg);
|
||||||
|
color: var(--accent-crimson);
|
||||||
|
border: 1px solid var(--crimson-bd);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
.product-create-page .form-card .opt-tag {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
background: var(--background-lighter);
|
||||||
|
color: var(--black-alpha-56);
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
.product-create-page .form-card .card-sub {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--black-alpha-48);
|
||||||
|
margin: -10px 0 14px;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 字段:本页卡片内最后一个 field 去掉底距 */
|
||||||
|
.product-create-page .field-last { margin-bottom: 0; }
|
||||||
|
.product-create-page .form-card .field-hint { margin: 4px 0 8px; }
|
||||||
|
|
||||||
|
/* ─── 原图槽位 ─── */
|
||||||
|
.product-create-page .photo-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.product-create-page .photo-slot {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border: 1px dashed var(--border-faint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--background-lighter);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--black-alpha-32);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.product-create-page .photo-slot-add { cursor: pointer; }
|
||||||
|
.product-create-page .photo-slot-add:hover {
|
||||||
|
border-color: var(--heat);
|
||||||
|
color: var(--heat);
|
||||||
|
background: var(--heat-8);
|
||||||
|
}
|
||||||
|
.product-create-page .photo-slot .plus {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.product-create-page .photo-slot .plus svg { width: 12px; height: 12px; }
|
||||||
|
.product-create-page .photo-slot .slot-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
left: 5px;
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--black-alpha-48);
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 上传提示(虚线 tip) ─── */
|
||||||
|
.product-create-page .upload-tip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--heat-8);
|
||||||
|
border: 1px dashed var(--heat-40);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent-black);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.product-create-page .upload-tip svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--heat);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.product-create-page .upload-tip strong { color: var(--heat); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ─── AI 提示 banner(虚线 tip · 中性) ─── */
|
||||||
|
.product-create-page .ai-tip {
|
||||||
|
margin: -6px 0 16px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--background-lighter);
|
||||||
|
border: 1px dashed var(--border-faint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--black-alpha-64);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.product-create-page .ai-tip svg {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
color: var(--heat);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.product-create-page .ai-tip strong { color: var(--accent-black); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ─── 卖点列表 · 复用 §4.17.5 .bullet-list 语义 ───
|
||||||
|
共享定义 scope 在 .np-body 下,这里把同一套 token 规则
|
||||||
|
挂到本页根,视觉与 restraint.css 完全一致。 */
|
||||||
|
.product-create-page .bullet-list { list-style: none; padding: 0; }
|
||||||
|
.product-create-page .bullet-list li {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--background-lighter);
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--accent-black);
|
||||||
|
transition: border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.product-create-page .bullet-list li.bl-item:hover { border-color: var(--black-alpha-24); }
|
||||||
|
.product-create-page .bullet-list li.bl-add { background: var(--surface); border-style: dashed; }
|
||||||
|
.product-create-page .bullet-list li.bl-add:focus-within { border-color: var(--heat-40); }
|
||||||
|
.product-create-page .bullet-list .num {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--black-alpha-56);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.product-create-page .bullet-list li.bl-add .num {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--heat);
|
||||||
|
border-color: var(--heat-40);
|
||||||
|
}
|
||||||
|
.product-create-page .bullet-list .bl-text { flex: 1; min-width: 0; }
|
||||||
|
.product-create-page .bullet-list .bl-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 24px;
|
||||||
|
border: 0;
|
||||||
|
padding: 0 4px;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--accent-black);
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.product-create-page .bullet-list .bl-input::placeholder { color: var(--black-alpha-48); }
|
||||||
|
.product-create-page .bullet-list .bl-x {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--black-alpha-32);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--t-base), background var(--t-base), color var(--t-base);
|
||||||
|
}
|
||||||
|
.product-create-page .bullet-list li.bl-item:hover .bl-x { opacity: 1; }
|
||||||
|
.product-create-page .bullet-list .bl-x:hover { background: var(--crimson-bg); color: var(--accent-crimson); }
|
||||||
|
.product-create-page .bullet-list .bl-x svg { width: 11px; height: 11px; }
|
||||||
|
|
||||||
|
/* ─── 底部操作行(吸底) ─── */
|
||||||
|
.product-create-page .form-foot {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 14px 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.product-create-page .form-foot .req-info {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--black-alpha-48);
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
.product-create-page .form-foot .req-info .ok { color: var(--accent-forest); }
|
||||||
|
.product-create-page .form-foot .req-info .miss { color: var(--accent-crimson); }
|
||||||
|
.product-create-page .form-foot .foot-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 响应式 · 窄屏单列 ─── */
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.product-create-page .form-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
312
core/frontend/src/project-wizard-page.css
Normal file
312
core/frontend/src/project-wizard-page.css
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
/* 新建视频项目 · 向导页 · 从 public/exact/projects-new.html 内联 <style> 忠实移植。
|
||||||
|
整段 scope 进 .project-wizard-page,避免污染同文件的 ProjectsPage(.projects-page)。
|
||||||
|
token + 共享组件(.btn/.field/.input/.textarea/.select/.placeholder/.pp-chip/.pp-menu .mi)走全局 design-restraint;
|
||||||
|
此处只覆盖向导专属:.wizard 网格 / .steps 步骤轨 / .pp- 商品选择器 / .opt-card / .source-card / .wiz-start-bar。 */
|
||||||
|
.project-wizard-page {
|
||||||
|
/* ── 两栏栅格:左 sticky 步骤轨 + 右主体 ── */
|
||||||
|
.wizard { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 36px; align-items: start; max-width: 1400px; }
|
||||||
|
|
||||||
|
.steps {
|
||||||
|
position: sticky;
|
||||||
|
top: calc(64px + 24px);
|
||||||
|
align-self: start;
|
||||||
|
max-height: calc(100vh - 64px - 48px);
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 单页式主体:Step 1 + Step 2 同时显示,底部「开始」CTA ── */
|
||||||
|
.wiz-body { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.step-pane-wrap { display: block; }
|
||||||
|
|
||||||
|
/* ── 左侧步骤轨 .step ── */
|
||||||
|
.step { display: flex; gap: 12px; padding: 12px 0; position: relative; }
|
||||||
|
.step:not(:last-child)::after { content: ''; position: absolute; left: 11px; top: 36px; width: 1px; height: calc(100% - 24px); background: var(--border-faint); }
|
||||||
|
.step .num { width: 24px; height: 24px; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); display: grid; place-items: center; font-size: 11px; font-weight: 600; color: var(--black-alpha-48); flex-shrink: 0; z-index: 1; font-family: var(--font-mono); }
|
||||||
|
.step .num svg { width: 12px; height: 12px; }
|
||||||
|
.step.done .num { background: var(--accent-black); border-color: var(--accent-black); color: var(--accent-white); }
|
||||||
|
.step.active .num { background: var(--heat); border-color: var(--heat); color: var(--accent-white); }
|
||||||
|
.step .label { font-size: 13.5px; font-weight: 500; color: var(--black-alpha-56); padding-top: 2px; }
|
||||||
|
.step .desc { font-size: 11.5px; color: var(--black-alpha-48); padding-top: 3px; line-height: 1.4; font-family: var(--font-mono); letter-spacing: .02em; }
|
||||||
|
.step.active .label { color: var(--accent-black); font-weight: 600; }
|
||||||
|
.step.done .label { color: var(--black-alpha-56); }
|
||||||
|
.step.done:not(:last-child)::after { background: var(--accent-black); }
|
||||||
|
.step.clickable { cursor: pointer; }
|
||||||
|
.step.clickable:hover .label { color: var(--heat); }
|
||||||
|
.step.clickable:hover .num { border-color: var(--heat); }
|
||||||
|
|
||||||
|
/* ── 主体卡片 .wiz-pane ── */
|
||||||
|
.wiz-pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 22px 24px; margin-bottom: 14px; }
|
||||||
|
.wiz-pane:last-child { margin-bottom: 0; }
|
||||||
|
.wiz-step-h { margin-bottom: 18px; }
|
||||||
|
.wiz-step-h h2 { font-size: 20px; font-weight: 600; letter-spacing: -.015em; }
|
||||||
|
.wiz-step-h p { font-size: 13px; color: var(--black-alpha-56); margin-top: 6px; line-height: 1.6; }
|
||||||
|
|
||||||
|
/* ── Step 1 · 商品选择器 toolbar(沿用商品库视觉,.pp- 命名空间)── */
|
||||||
|
.pp-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||||
|
.pp-toolbar .search-inline {
|
||||||
|
flex: 1; min-width: 220px; max-width: 340px;
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
height: 34px; padding: 0 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
transition: border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.pp-toolbar .search-inline:focus-within { border-color: var(--heat-40); }
|
||||||
|
.pp-toolbar .search-inline svg { width: 14px; height: 14px; color: var(--black-alpha-48); flex-shrink: 0; }
|
||||||
|
.pp-toolbar .search-inline input { flex: 1; min-width: 0; height: 100%; border: 0; outline: 0; background: transparent; font-size: 13px; color: var(--accent-black); font-family: inherit; }
|
||||||
|
.pp-toolbar .search-inline input::placeholder { color: var(--black-alpha-48); }
|
||||||
|
.pp-toolbar .pp-chip-wrap { position: relative; }
|
||||||
|
.pp-toolbar .pp-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
height: 34px; padding: 0 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
font-size: 13px; font-family: inherit;
|
||||||
|
color: var(--black-alpha-72);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--t-base), color var(--t-base);
|
||||||
|
}
|
||||||
|
.pp-toolbar .pp-chip:hover { border-color: var(--black-alpha-32); color: var(--accent-black); }
|
||||||
|
.pp-toolbar .pp-chip.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); }
|
||||||
|
.pp-toolbar .pp-chip svg { width: 11px; height: 11px; opacity: .6; }
|
||||||
|
.pp-toolbar .pp-menu {
|
||||||
|
position: absolute; top: calc(100% + 4px); left: 0;
|
||||||
|
min-width: 160px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
box-shadow: var(--shadow-floating);
|
||||||
|
padding: 4px;
|
||||||
|
display: none;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.pp-toolbar .pp-chip-wrap.open .pp-menu { display: block; }
|
||||||
|
.pp-toolbar .pp-menu .mi {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--accent-black);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pp-toolbar .pp-menu .mi:hover { background: var(--background-lighter); }
|
||||||
|
.pp-toolbar .pp-menu .mi.selected { color: var(--heat); font-weight: 600; }
|
||||||
|
.pp-toolbar .pp-menu .mi-check { width: 12px; height: 12px; opacity: 0; flex-shrink: 0; }
|
||||||
|
.pp-toolbar .pp-menu .mi.selected .mi-check { opacity: 1; }
|
||||||
|
.pp-toolbar .pp-clear {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
height: 30px; padding: 0 10px;
|
||||||
|
background: transparent; border: 0; border-radius: var(--r-sm);
|
||||||
|
color: var(--black-alpha-56); font-size: 12.5px; font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pp-toolbar .pp-clear:hover { color: var(--accent-crimson); background: var(--crimson-bg); }
|
||||||
|
.pp-toolbar .pp-clear svg { width: 11px; height: 11px; }
|
||||||
|
|
||||||
|
.pp-result-meta {
|
||||||
|
font-family: var(--font-mono); font-size: 11.5px;
|
||||||
|
color: var(--black-alpha-48); letter-spacing: .02em;
|
||||||
|
margin: 4px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Step 1 · 商品网格(固定 4 列,沿用 .product-card 视觉)── */
|
||||||
|
.pp-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
|
||||||
|
.pp-grid .product-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
cursor: pointer; position: relative; overflow: hidden;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
transition: background .15s, border-color .15s, transform .15s;
|
||||||
|
}
|
||||||
|
.pp-grid .product-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }
|
||||||
|
.pp-grid .product-card.selected { border-color: var(--heat); background: var(--heat-12); }
|
||||||
|
.pp-grid .product-card.selected::after {
|
||||||
|
content: ''; position: absolute; top: 0; right: 0;
|
||||||
|
width: 0; height: 0;
|
||||||
|
border-top: 28px solid var(--heat);
|
||||||
|
border-left: 28px solid transparent;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.pp-grid .product-card.selected::before {
|
||||||
|
content: ''; position: absolute; top: 4px; right: 4px;
|
||||||
|
width: 10px; height: 10px;
|
||||||
|
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23ffffff' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 8 7 12 13 4'/%3E%3C/svg%3E") no-repeat center / contain;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.pp-grid .product-thumb { aspect-ratio: 1.4 / 1; }
|
||||||
|
.pp-grid .product-body { padding: 14px 14px 12px; flex: 1; }
|
||||||
|
.pp-grid .product-name {
|
||||||
|
font-size: 14px; font-weight: 600; color: var(--accent-black);
|
||||||
|
line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pp-grid .product-cat {
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
margin-top: 8px; padding: 2px 8px;
|
||||||
|
background: var(--background-lighter); color: var(--black-alpha-72);
|
||||||
|
border-radius: var(--r-sm); font-size: 11.5px;
|
||||||
|
}
|
||||||
|
.pp-grid .product-date {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px; color: var(--black-alpha-48);
|
||||||
|
margin-top: 10px; letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
.pp-grid .product-card.selected .product-cat { background: var(--surface); color: var(--heat); }
|
||||||
|
|
||||||
|
/* ── Step 1 · 创建新商品 空卡 ── */
|
||||||
|
.pp-grid .pp-create-card {
|
||||||
|
border: 1.5px dashed var(--black-alpha-24);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: 10px; min-height: 220px;
|
||||||
|
color: var(--black-alpha-48);
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.pp-grid .pp-create-card:hover { border-color: var(--heat); color: var(--heat); background: var(--heat-12); }
|
||||||
|
.pp-grid .pp-create-card .pc-plus {
|
||||||
|
width: 44px; height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--heat); color: var(--accent-white);
|
||||||
|
display: grid; place-items: center;
|
||||||
|
transition: filter var(--t-base);
|
||||||
|
}
|
||||||
|
.pp-grid .pp-create-card:hover .pc-plus { filter: brightness(1.06); }
|
||||||
|
.pp-grid .pp-create-card .pc-plus svg { width: 18px; height: 18px; }
|
||||||
|
.pp-grid .pp-create-card .pc-t { font-size: 13px; font-weight: 600; }
|
||||||
|
.pp-grid .pp-create-card .pc-d { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .02em; }
|
||||||
|
|
||||||
|
/* ── Step 1 · 列表视图 ── */
|
||||||
|
.pp-grid.list-view { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.pp-grid.list-view .product-card { flex-direction: row; align-items: center; }
|
||||||
|
.pp-grid.list-view .product-thumb { width: 96px; aspect-ratio: 1.4 / 1; flex-shrink: 0; }
|
||||||
|
.pp-grid.list-view .product-body { flex: 1; padding: 10px 14px; }
|
||||||
|
.pp-grid.list-view .pp-create-card { flex-direction: row; min-height: 56px; gap: 12px; }
|
||||||
|
.pp-grid.list-view .pp-create-card .pc-plus { width: 32px; height: 32px; }
|
||||||
|
|
||||||
|
/* ── Step 1 · 空筛选结果 ── */
|
||||||
|
.pp-empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding: 48px 24px; text-align: center;
|
||||||
|
border: 1px dashed var(--border-faint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--background-lighter);
|
||||||
|
color: var(--black-alpha-48);
|
||||||
|
font-size: 12.5px; font-family: var(--font-mono); letter-spacing: .02em;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.pp-empty .reset { display: inline-block; margin-top: 8px; color: var(--heat); cursor: pointer; }
|
||||||
|
|
||||||
|
/* ── Step 1 · 分页 ── */
|
||||||
|
.pp-pager {
|
||||||
|
display: flex; align-items: center; gap: 16px;
|
||||||
|
margin-top: 18px; padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--border-faint);
|
||||||
|
font-size: 12.5px; color: var(--black-alpha-56);
|
||||||
|
}
|
||||||
|
.pp-pager .total { font-family: var(--font-mono); letter-spacing: .02em; }
|
||||||
|
.pp-pager .pages { display: inline-flex; gap: 4px; margin-left: auto; }
|
||||||
|
.pp-pager .pages button {
|
||||||
|
min-width: 28px; height: 28px; padding: 0 8px;
|
||||||
|
border: 1px solid var(--border-faint); background: var(--surface);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
cursor: pointer; font-size: 12.5px; color: var(--black-alpha-72); font-family: inherit;
|
||||||
|
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
|
||||||
|
}
|
||||||
|
.pp-pager .pages button:hover:not(.active):not(:disabled) { border-color: var(--black-alpha-32); color: var(--accent-black); }
|
||||||
|
.pp-pager .pages button.active { background: var(--heat); color: var(--accent-white); border-color: var(--heat); font-weight: 600; }
|
||||||
|
.pp-pager .pages button:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
.pp-pager .pages .ellipsis {
|
||||||
|
min-width: 22px; height: 28px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--black-alpha-48); font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.pp-pager .page-size {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
height: 28px; padding: 0 10px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-family: inherit; font-size: 12.5px; color: var(--black-alpha-72);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Step 1 · 底部提示 ── */
|
||||||
|
.pp-bottom-tip {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--background-lighter);
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
font-size: 12.5px; color: var(--black-alpha-56);
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.pp-bottom-tip svg { width: 14px; height: 14px; flex-shrink: 0; color: var(--black-alpha-48); }
|
||||||
|
.pp-bottom-tip a { color: var(--heat); cursor: pointer; text-decoration: none; }
|
||||||
|
.pp-bottom-tip a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ── Step 2 · 配置字段(共享 .field/.input/.select 走全局)── */
|
||||||
|
.config-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; align-items: start; margin-bottom: 16px; }
|
||||||
|
.config-row .field { margin-bottom: 0; }
|
||||||
|
.duration-select { cursor: pointer; }
|
||||||
|
|
||||||
|
/* ── Step 2 · 选项卡 .opt-card ── */
|
||||||
|
.opt-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
||||||
|
.opt-row.cols-4 { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
.opt-row.cols-6 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.opt-card { border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 14px; background: var(--surface); cursor: pointer; position: relative; display: flex; flex-direction: column; min-width: 0; transition: background var(--t-base), border-color var(--t-base); }
|
||||||
|
.opt-card:hover { background: var(--background-lighter); }
|
||||||
|
.opt-card.selected { border-color: var(--heat); background: var(--heat-12); }
|
||||||
|
.opt-card.selected::after { content: ''; position: absolute; top: 8px; right: 10px; width: 16px; height: 16px; background-color: var(--heat); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; background-size: 10px 10px; border-radius: var(--r-sm); }
|
||||||
|
.opt-card h4 { font-size: 13px; font-weight: 600; }
|
||||||
|
.opt-card .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); margin-top: 3px; letter-spacing: .02em; }
|
||||||
|
.opt-card .note { font-size: 11.5px; color: var(--black-alpha-56); margin-top: 6px; line-height: 1.5; }
|
||||||
|
.opt-card .metric { margin-top: auto; padding-top: 10px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
|
||||||
|
.opt-card .metric .val { color: var(--accent-black); font-weight: 500; }
|
||||||
|
.opt-card.selected .metric .val { color: var(--heat); }
|
||||||
|
.opt-card .badge { font-family: var(--font-mono); font-size: 9.5px; padding: 1px 6px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-48); display: inline-block; margin-top: 8px; letter-spacing: .04em; align-self: flex-start; }
|
||||||
|
.opt-card.selected .badge { color: var(--heat); border-color: var(--heat-20); }
|
||||||
|
|
||||||
|
@media (min-width: 1280px) { .opt-row.cols-6 { grid-template-columns: repeat(6, 1fr); } }
|
||||||
|
|
||||||
|
/* ── Step 2 · 卖点胶囊 .theme-pill ── */
|
||||||
|
.theme-pill-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.theme-pill { display: inline-flex; gap: 6px; align-items: center; height: 36px; padding: 0 16px; border: 1px solid var(--border-faint); border-radius: 999px; background: var(--surface); font-size: 13px; font-weight: 500; font-family: inherit; cursor: pointer; color: var(--accent-black); transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }
|
||||||
|
.theme-pill:hover { background: var(--background-lighter); }
|
||||||
|
.theme-pill.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat); font-weight: 600; }
|
||||||
|
.theme-pill svg { width: 13px; height: 13px; }
|
||||||
|
|
||||||
|
/* ── Step 2 · 人设推荐气泡 .reco-bubble ── */
|
||||||
|
.reco-bubble { position: relative; margin-top: 10px; padding: 10px 14px; background: var(--heat-12); border: 1px solid var(--heat-20); border-radius: var(--r-md); display: flex; align-items: center; gap: 12px; font-size: 12.5px; color: var(--accent-black); }
|
||||||
|
.reco-bubble::before { content: ''; position: absolute; top: -5px; left: 28px; width: 9px; height: 9px; background: var(--heat-12); border-left: 1px solid var(--heat-20); border-top: 1px solid var(--heat-20); transform: rotate(45deg); }
|
||||||
|
.reco-bubble .ic { color: var(--heat); flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; }
|
||||||
|
.reco-bubble .ic svg, .reco-bubble .dismiss svg { display: block; width: 16px; height: 16px; }
|
||||||
|
.reco-bubble .txt { flex: 1; line-height: 1.5; }
|
||||||
|
.reco-bubble .txt strong { color: var(--heat); font-weight: 600; }
|
||||||
|
.reco-bubble .txt .meta { display: block; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); margin-top: 2px; letter-spacing: .02em; }
|
||||||
|
.reco-bubble .btn-apply { height: 28px; padding: 0 12px; background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: var(--r-md); font-size: 12px; font-weight: 600; cursor: pointer; flex-shrink: 0; box-shadow: var(--shadow-cta); transition: box-shadow var(--t-base); font-family: inherit; }
|
||||||
|
.reco-bubble .btn-apply:hover { box-shadow: var(--shadow-cta-hover); }
|
||||||
|
.reco-bubble .dismiss { background: transparent; color: var(--black-alpha-48); border: 0; width: 24px; height: 24px; padding: 0; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
|
||||||
|
.reco-bubble .dismiss:hover { color: var(--accent-black); }
|
||||||
|
|
||||||
|
/* ── 底部「开始」CTA ── */
|
||||||
|
.wiz-start-bar { display: flex; justify-content: flex-end; padding: 20px 0 8px; }
|
||||||
|
.wiz-start-bar .btn-start { height: 44px; padding: 0 36px; background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: 999px; font-size: 14px; font-weight: 600; cursor: pointer; box-shadow: var(--shadow-cta); display: inline-flex; align-items: center; gap: 8px; font-family: inherit; transition: box-shadow var(--t-base), opacity var(--t-base); }
|
||||||
|
.wiz-start-bar .btn-start:hover:not(.disabled) { box-shadow: var(--shadow-cta-hover); }
|
||||||
|
.wiz-start-bar .btn-start.disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
.wiz-start-bar .btn-start svg { width: 14px; height: 14px; }
|
||||||
|
|
||||||
|
/* ── 响应式:窄屏单列 ── */
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.pp-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.config-row { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.pp-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,52 +1,566 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { ArrowLeft, ArrowRight, Check, Grid2X2, List, RefreshCw, Search, WandSparkles } from "lucide-react";
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Check,
|
||||||
|
Download,
|
||||||
|
Grid2X2,
|
||||||
|
List,
|
||||||
|
MoreHorizontal,
|
||||||
|
Quote,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
WandSparkles
|
||||||
|
} from "lucide-react";
|
||||||
import type { AITask, Asset, ModelConfig, Product } from "../types";
|
import type { AITask, Asset, ModelConfig, Product } from "../types";
|
||||||
import type { Page } from "./route-config";
|
import type { Page } from "./route-config";
|
||||||
import { statusPill } from "./stage-config";
|
import { statusPill } from "./stage-config";
|
||||||
|
import "../ai-tools-page.css";
|
||||||
|
|
||||||
|
const TASK_TYPE_LABEL: Record<string, string> = {
|
||||||
|
model: "模特上身图",
|
||||||
|
platform: "平台套图",
|
||||||
|
image: "图片创作",
|
||||||
|
model_photo: "模特上身图",
|
||||||
|
platform_cover: "平台套图",
|
||||||
|
image_optimize: "图片创作"
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
succeeded: "已完成",
|
||||||
|
completed: "已完成",
|
||||||
|
done: "已完成",
|
||||||
|
ok: "已完成",
|
||||||
|
skipped: "已跳过",
|
||||||
|
failed: "失败",
|
||||||
|
error: "失败",
|
||||||
|
running: "生成中",
|
||||||
|
queued: "排队中",
|
||||||
|
polling: "生成中",
|
||||||
|
needs_review: "待确认"
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusText(status: string) {
|
||||||
|
return STATUS_LABEL[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page) => void; aiTasks: AITask[] }) {
|
export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page) => void; aiTasks: AITask[] }) {
|
||||||
const cards = [
|
const cards = [
|
||||||
{ page: "modelPhoto" as Page, tag: "[ MODEL · TRY-ON ]", title: "模特上身图", desc: "选择模特,AI 生成商品模特上身效果图", cost: "≈ ¥0.30 / 张" },
|
{
|
||||||
{ page: "platformCover" as Page, tag: "[ PLATFORM · KIT ]", title: "平台套图", desc: "选择平台模板,AI 生成电商平台套图", cost: "≈ ¥0.50 / 张" },
|
page: "modelPhoto" as Page,
|
||||||
{ page: "imageOptimize" as Page, tag: "[ IMAGE · STUDIO ]", title: "图片创作", desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写", cost: "≈ ¥0.40 / 组" }
|
tag: "[ MODEL · TRY-ON ]",
|
||||||
|
title: "模特上身图",
|
||||||
|
desc: "选择模特,AI 生成商品模特上身效果图",
|
||||||
|
cost: "≈ ¥0.30 / 张"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page: "platformCover" as Page,
|
||||||
|
tag: "[ PLATFORM · KIT ]",
|
||||||
|
title: "平台套图",
|
||||||
|
desc: "选择平台模板,AI 生成电商平台套图",
|
||||||
|
cost: "≈ ¥0.50 / 张"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page: "imageOptimize" as Page,
|
||||||
|
tag: "[ IMAGE · STUDIO ]",
|
||||||
|
title: "图片创作",
|
||||||
|
desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写",
|
||||||
|
cost: "≈ ¥0.40 / 组"
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const acc = { gen: 0, ok: 0, err: 0 };
|
||||||
|
for (const task of aiTasks) {
|
||||||
|
const pill = statusPill(task.status);
|
||||||
|
if (pill === "ok") acc.ok += 1;
|
||||||
|
else if (pill === "err") acc.err += 1;
|
||||||
|
else if (pill === "info") acc.gen += 1;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [aiTasks]);
|
||||||
|
|
||||||
|
const visible = aiTasks.slice(0, 8);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="asset-factory">
|
||||||
<div className="page-head"><div><h1>图片生成</h1><div className="sub"><span className="mono">// 一键生成</span><span>·</span><span>电商视觉素材,提升内容制作效率</span></div></div></div>
|
<div className="page-head">
|
||||||
<div className="factory-hero">{cards.map((card) => <article className="factory-card with-corners" key={card.title}><span className="corner-tr">+</span><span className="corner-bl">+</span><div className="factory-body"><div className="factory-text"><span className="factory-tag">{card.tag}</span><div className="factory-title">{card.title}</div><div className="factory-desc">{card.desc}</div><div className="factory-cta"><button className="btn btn-primary btn-lg" type="button" onClick={() => navigate(card.page)}>开始生成<ArrowRight size={13} /></button><span className="cost">[ {card.cost} ]</span></div></div><div className="factory-visual"><div className="placeholder main"><span className="ph-frame">{card.title}</span></div></div></div></article>)}</div>
|
<div>
|
||||||
<div className="section-h"><h2>任务中心</h2><span className="more">// {aiTasks.length} 个真实 AI 任务</span></div>
|
<h1>图片生成</h1>
|
||||||
<div className="toolbar"><div className="search-inline"><Search size={14} /><input className="input" placeholder="搜索任务名" /></div><span className="spacer" /><div className="view-toggle"><button type="button" className="active"><List size={13} />列表</button><button type="button"><Grid2X2 size={13} />网格</button></div></div>
|
<div className="sub">
|
||||||
<table className="t"><thead><tr><th>任务</th><th>类型</th><th>状态</th><th /></tr></thead><tbody>{aiTasks.slice(0, 8).map((task) => <tr key={task.id}><td>{task.id.slice(0, 8)}</td><td>{task.task_type}</td><td><span className={`pill ${statusPill(task.status)}`}><span className="dot" />{task.status}</span></td><td /></tr>)}</tbody></table>
|
<span className="mono">// 一键生成</span>
|
||||||
</>
|
<span>·</span>
|
||||||
|
<span>电商视觉素材,提升内容制作效率</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="factory-hero">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<article className="factory-card with-corners" key={card.title}>
|
||||||
|
<span className="corner-tr" aria-hidden />
|
||||||
|
<span className="corner-bl" aria-hidden />
|
||||||
|
<div className="factory-body">
|
||||||
|
<div className="factory-text">
|
||||||
|
<span className="factory-tag">{card.tag}</span>
|
||||||
|
<div className="factory-title">{card.title}</div>
|
||||||
|
<div className="factory-desc">{card.desc}</div>
|
||||||
|
</div>
|
||||||
|
<div className="factory-cta">
|
||||||
|
<button className="btn btn-primary btn-lg" type="button" onClick={() => navigate(card.page)}>
|
||||||
|
开始生成
|
||||||
|
<ArrowRight size={13} />
|
||||||
|
</button>
|
||||||
|
<span className="cost">[ {card.cost} ]</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section-h">
|
||||||
|
<h2>任务中心</h2>
|
||||||
|
<span className="sub-mono">
|
||||||
|
// {aiTasks.length} 个 · {counts.gen} 生成中 · {counts.ok} 已完成 · {counts.err} 失败
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar">
|
||||||
|
<div className="search-inline">
|
||||||
|
<Search size={14} />
|
||||||
|
<input className="input" placeholder="搜索任务名" />
|
||||||
|
</div>
|
||||||
|
<span className="spacer" />
|
||||||
|
<div className="view-toggle">
|
||||||
|
<button type="button" className="active">
|
||||||
|
<List size={13} />
|
||||||
|
列表
|
||||||
|
</button>
|
||||||
|
<button type="button">
|
||||||
|
<Grid2X2 size={13} />
|
||||||
|
网格
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="result-meta">
|
||||||
|
// 显示 {visible.length} / {aiTasks.length} 个任务
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{aiTasks.length === 0 ? (
|
||||||
|
<div className="task-empty">
|
||||||
|
<div className="mono">// NO TASKS YET</div>
|
||||||
|
<div>还没有任务,去上方选一个工序开始生成吧</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="task-list-view">
|
||||||
|
<table className="t">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: "42%" }}>任务</th>
|
||||||
|
<th style={{ width: 160 }}>进度</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th style={{ width: 140 }}>任务 ID</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visible.map((task) => {
|
||||||
|
const pill = statusPill(task.status);
|
||||||
|
const typeLabel = TASK_TYPE_LABEL[task.task_type] || task.task_type;
|
||||||
|
return (
|
||||||
|
<tr key={task.id}>
|
||||||
|
<td>
|
||||||
|
<div className="task-name-cell">
|
||||||
|
<div className="placeholder task-thumb">
|
||||||
|
<span className="ph-frame">{task.id.slice(0, 4)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="task-name">{typeLabel}</div>
|
||||||
|
<div className="task-sub">// {task.task_type}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{pill === "info" ? (
|
||||||
|
<div className="task-list-prog">
|
||||||
|
<div className="bar">
|
||||||
|
<span style={{ width: "60%" }} />
|
||||||
|
</div>
|
||||||
|
<span className="pct">60%</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="muted-2 mono" style={{ fontSize: 11 }}>
|
||||||
|
{pill === "ok" ? "已完成" : "—"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`pill ${pill}`}>
|
||||||
|
<span className="dot" />
|
||||||
|
{statusText(task.status)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="muted-2">{task.id.slice(0, 8)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImageWorkbenchPage({ mode, products, assets, modelConfigs, onBack, navigate }: {
|
type WorkMode = "image" | "model" | "cover";
|
||||||
mode: "image" | "model" | "cover";
|
|
||||||
|
const MODE_META: Record<
|
||||||
|
WorkMode,
|
||||||
|
{
|
||||||
|
title: string;
|
||||||
|
tag: string;
|
||||||
|
desc: string;
|
||||||
|
ratio: string;
|
||||||
|
ratioVar: string;
|
||||||
|
pickStep?: { num: string; title: string; sub: string; kind: "model" | "platform" };
|
||||||
|
promptTemplate: (productTitle: string) => string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
image: {
|
||||||
|
title: "图片创作",
|
||||||
|
tag: "[ IMAGE · STUDIO ]",
|
||||||
|
desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写。",
|
||||||
|
ratio: "1:1",
|
||||||
|
ratioVar: "1 / 1",
|
||||||
|
promptTemplate: (title) => `${title},电商高转化视觉,干净背景,商品主体清晰`
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
title: "模特上身图",
|
||||||
|
tag: "[ MODEL · TRY-ON ]",
|
||||||
|
desc: "选择模特和商品,生成电商模特上身图。",
|
||||||
|
ratio: "3:4",
|
||||||
|
ratioVar: "3 / 4",
|
||||||
|
pickStep: { num: "2", title: "选择模特", sub: "// 可多选 · 一次生成多套", kind: "model" },
|
||||||
|
promptTemplate: (title) => `${title},模特上身展示,自然光,真实质感,电商主图`
|
||||||
|
},
|
||||||
|
cover: {
|
||||||
|
title: "平台套图",
|
||||||
|
tag: "[ PLATFORM · KIT ]",
|
||||||
|
desc: "选择平台模板,一键生成主图 / 封面 / 详情套图。",
|
||||||
|
ratio: "4:5",
|
||||||
|
ratioVar: "4 / 5",
|
||||||
|
pickStep: { num: "2", title: "选择平台", sub: "// 多选平台 · 自动套版", kind: "platform" },
|
||||||
|
promptTemplate: (title) => `${title},电商平台套图,统一视觉,主图 + 详情排版`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const RATIO_OPTIONS = ["1:1", "3:4", "4:5", "9:16", "16:9"];
|
||||||
|
const COUNT_OPTIONS = ["1", "2", "4"];
|
||||||
|
const PLATFORM_OPTIONS = [
|
||||||
|
{ id: "tb", name: "淘宝" },
|
||||||
|
{ id: "dy", name: "抖音" },
|
||||||
|
{ id: "xhs", name: "小红书" },
|
||||||
|
{ id: "pdd", name: "拼多多" },
|
||||||
|
{ id: "jd", name: "京东" },
|
||||||
|
{ id: "ks", name: "快手" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ImageWorkbenchPage({
|
||||||
|
mode,
|
||||||
|
products,
|
||||||
|
assets,
|
||||||
|
modelConfigs,
|
||||||
|
onBack,
|
||||||
|
navigate
|
||||||
|
}: {
|
||||||
|
mode: WorkMode;
|
||||||
products: Product[];
|
products: Product[];
|
||||||
assets: Asset[];
|
assets: Asset[];
|
||||||
modelConfigs: ModelConfig[];
|
modelConfigs: ModelConfig[];
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
navigate?: (page: Page) => void;
|
navigate?: (page: Page) => void;
|
||||||
}) {
|
}) {
|
||||||
const meta = {
|
const meta = MODE_META[mode];
|
||||||
image: { title: "图片创作", tag: "[ IMAGE · STUDIO ]", desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写。", ratio: "1:1" },
|
|
||||||
model: { title: "模特上身图", tag: "[ MODEL · TRY-ON ]", desc: "选择模特和商品,生成电商模特上身图。", ratio: "3:4" },
|
|
||||||
cover: { title: "平台套图", tag: "[ PLATFORM · KIT ]", desc: "选择平台模板,一键生成主图 / 封面 / 详情套图。", ratio: "4:5" }
|
|
||||||
}[mode];
|
|
||||||
const [productId, setProductId] = useState(products[0]?.id || "");
|
const [productId, setProductId] = useState(products[0]?.id || "");
|
||||||
const [prompt, setPrompt] = useState(`${products[0]?.title || "商品"},电商高转化视觉,干净背景,商品主体清晰`);
|
|
||||||
const product = products.find((item) => item.id === productId) || products[0];
|
const product = products.find((item) => item.id === productId) || products[0];
|
||||||
|
const [prompt, setPrompt] = useState(meta.promptTemplate(products[0]?.title || "商品"));
|
||||||
|
const [ratio, setRatio] = useState(meta.ratio);
|
||||||
|
const [count, setCount] = useState("4");
|
||||||
|
const [pickedIds, setPickedIds] = useState<string[]>([]);
|
||||||
|
|
||||||
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
|
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
|
||||||
|
const modelOptions = useMemo(() => modelConfigs.slice(0, 6), [modelConfigs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (product) setPrompt(`${product.title},电商高转化视觉,干净背景,商品主体清晰`);
|
if (product) setPrompt(meta.promptTemplate(product.title));
|
||||||
}, [productId]);
|
// mode 或商品切换都重置 prompt 与默认比例
|
||||||
|
setRatio(meta.ratio);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [productId, mode]);
|
||||||
|
|
||||||
|
function togglePick(id: string) {
|
||||||
|
setPickedIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratioVar = ratio.replace(":", " / ");
|
||||||
|
const candidateCount = Math.max(1, Number(count) || 4);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tool-shell">
|
<div className="image-workbench">
|
||||||
<div className="tool-topbar"><button className="back-pill" type="button" onClick={onBack}><ArrowLeft size={14} />返回</button><div><h1>{meta.title}</h1><div className="sub"><span className="mono">{meta.tag}</span> {meta.desc}</div></div><span className="spacer" />{mode === "model" && navigate && <button className="btn" type="button" onClick={() => navigate("modelPhotoDemoA")}>方案 A</button>}<button className="btn btn-primary" type="button"><WandSparkles size={13} />生成图片</button></div>
|
{/* 顶栏 · 返回 + mode 标题 + 主操作 */}
|
||||||
<div className="tool-layout"><aside className="tool-rail"><div className="rail-h">商品空间</div>{products.slice(0, 8).map((item) => <button className={`rail-product ${productId === item.id ? "active" : ""}`} type="button" key={item.id} onClick={() => setProductId(item.id)}><div className="placeholder"><span className="ph-frame">{item.title.slice(0, 4)}</span></div><span>{item.title}</span></button>)}</aside><section className="tool-canvas"><div className="tool-toolbar"><button className="chip active">全部</button><span className="spacer" /><span className="mono muted-2">{imageModels[0]?.display_name || "Volcano Image"}</span></div><div className="result-board">{Array.from({ length: 4 }).map((_, index) => <button className="result-tile" type="button" key={index}><div className="placeholder"><span className="ph-frame">{meta.ratio} · v{index + 1}</span></div><div className="result-meta-row"><span>候选 {index + 1}</span><span className="pill info"><span className="dot" />待采用</span></div></button>)}</div><div className="candidate-actions"><button className="btn" type="button"><RefreshCw size={13} />重跑</button><button className="btn btn-primary" type="button"><Check size={13} />采用并入资产库</button></div></section><aside className="tool-side"><div className="pane"><h3>生成参数</h3><textarea className="textarea" value={prompt} onChange={(event) => setPrompt(event.target.value)} /></div><div className="pane"><h3>资产库</h3><div className="asset-mini-grid">{assets.slice(0, 6).map((asset) => <div className="placeholder" key={asset.id}><span className="ph-frame">{asset.asset_type}</span></div>)}</div></div></aside></div>
|
<div className="iw-topbar">
|
||||||
|
<button className="back-pill" type="button" onClick={onBack}>
|
||||||
|
<ArrowLeft size={14} />
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
<div className="iw-title">
|
||||||
|
<h1>{meta.title}</h1>
|
||||||
|
<div className="sub">
|
||||||
|
<span className="mono">{meta.tag}</span>
|
||||||
|
<span>{meta.desc}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="spacer" />
|
||||||
|
{mode === "model" && navigate && (
|
||||||
|
<button className="btn" type="button" onClick={() => navigate("modelPhotoDemoA")}>
|
||||||
|
方案 A
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-primary" type="button">
|
||||||
|
<WandSparkles size={13} />
|
||||||
|
生成图片
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="iw-layout">
|
||||||
|
{/* 最左 · 商品空间 */}
|
||||||
|
<aside className="iw-prod-space">
|
||||||
|
<div className="iw-ps-h">
|
||||||
|
<span className="mono">商品空间</span>
|
||||||
|
</div>
|
||||||
|
<div className="iw-ps-search">
|
||||||
|
<Search size={13} />
|
||||||
|
<input placeholder="搜索商品" />
|
||||||
|
</div>
|
||||||
|
<div className="iw-ps-list">
|
||||||
|
{products.length === 0 ? (
|
||||||
|
<div className="iw-ps-empty">
|
||||||
|
还没有商品
|
||||||
|
<br />
|
||||||
|
// NO PRODUCTS
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
products.slice(0, 12).map((item) => (
|
||||||
|
<button
|
||||||
|
className={`iw-prod-item ${productId === item.id ? "active" : ""}`}
|
||||||
|
type="button"
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => setProductId(item.id)}
|
||||||
|
>
|
||||||
|
<div className="placeholder thumb">
|
||||||
|
<span className="ph-frame">{item.title.slice(0, 2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="body">
|
||||||
|
<div className="nm">{item.title}</div>
|
||||||
|
<div className="sub">// {item.category || "未分类"}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 中 · 参数表单 */}
|
||||||
|
<section className="iw-form">
|
||||||
|
<div className="iw-step">
|
||||||
|
<div className="iw-step-h">
|
||||||
|
<span className="num">1</span>
|
||||||
|
<span className="title">商品与提示词</span>
|
||||||
|
</div>
|
||||||
|
<div className="iw-sub">
|
||||||
|
<div className="iw-sub-h">// 当前商品</div>
|
||||||
|
<div className="field" style={{ marginBottom: 0 }}>
|
||||||
|
<div className="iw-pv-line" style={{ fontSize: 13, color: "var(--accent-black)" }}>
|
||||||
|
{product?.title || "未选择商品"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="iw-sub">
|
||||||
|
<div className="iw-sub-h">// 提示词</div>
|
||||||
|
<textarea
|
||||||
|
className="textarea"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(event) => setPrompt(event.target.value)}
|
||||||
|
placeholder="描述你想要的画面…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{meta.pickStep && (
|
||||||
|
<div className="iw-step">
|
||||||
|
<div className="iw-step-h">
|
||||||
|
<span className="num">{meta.pickStep.num}</span>
|
||||||
|
<span className="title">{meta.pickStep.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="iw-sub-h">{meta.pickStep.sub}</div>
|
||||||
|
{meta.pickStep.kind === "model" ? (
|
||||||
|
<div className="iw-pick-grid">
|
||||||
|
{(modelOptions.length ? modelOptions : products.slice(0, 4)).map((item) => {
|
||||||
|
const id = "id" in item ? item.id : "";
|
||||||
|
const label = "display_name" in item ? item.display_name : (item as Product).title;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={id}
|
||||||
|
className={`iw-pick-card ${pickedIds.includes(id) ? "selected" : ""}`}
|
||||||
|
onClick={() => togglePick(id)}
|
||||||
|
>
|
||||||
|
<div className="placeholder m-thumb">
|
||||||
|
<span className="ph-frame">3:4</span>
|
||||||
|
</div>
|
||||||
|
<div className="m-name">{label}</div>
|
||||||
|
<div className="m-meta">// 模特</div>
|
||||||
|
<span className="m-check">
|
||||||
|
<Check size={11} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="iw-pick-grid platforms">
|
||||||
|
{PLATFORM_OPTIONS.map((item) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={item.id}
|
||||||
|
className={`iw-pick-card platforms-card ${pickedIds.includes(item.id) ? "selected" : ""}`}
|
||||||
|
onClick={() => togglePick(item.id)}
|
||||||
|
>
|
||||||
|
<div className="m-name">{item.name}</div>
|
||||||
|
<span className="m-check">
|
||||||
|
<Check size={11} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="iw-step">
|
||||||
|
<div className="iw-step-h">
|
||||||
|
<span className="num">{meta.pickStep ? "3" : "2"}</span>
|
||||||
|
<span className="title">生成参数</span>
|
||||||
|
</div>
|
||||||
|
<div className="iw-sub">
|
||||||
|
<div className="iw-sub-h">// 比例</div>
|
||||||
|
<div className="pill-row">
|
||||||
|
{RATIO_OPTIONS.map((value) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={value}
|
||||||
|
className={`opt ${ratio === value ? "active" : ""}`}
|
||||||
|
onClick={() => setRatio(value)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="iw-sub">
|
||||||
|
<div className="iw-sub-h">// 张数</div>
|
||||||
|
<div className="pill-row">
|
||||||
|
{COUNT_OPTIONS.map((value) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={value}
|
||||||
|
className={`opt ${count === value ? "active" : ""}`}
|
||||||
|
onClick={() => setCount(value)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="iw-cta">
|
||||||
|
<button className="btn btn-primary" type="button">
|
||||||
|
<WandSparkles size={13} />
|
||||||
|
立即生成
|
||||||
|
</button>
|
||||||
|
<div className="iw-cta-hint">
|
||||||
|
// {imageModels[0]?.display_name || "Volcano Image"} · 预估 {meta.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 右 · 结果预览 */}
|
||||||
|
<section className="iw-preview">
|
||||||
|
<div className="iw-pv-h">
|
||||||
|
<Quote className="quote-icon" />
|
||||||
|
<div className="pv-meta">
|
||||||
|
<b>{ratio}</b> · {count} 张 · {imageModels[0]?.display_name || "Volcano Image"}
|
||||||
|
</div>
|
||||||
|
<div className="pv-line">{prompt}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="gen-card">
|
||||||
|
<div className="gen-meta">
|
||||||
|
<span>Airshelf v2</span>
|
||||||
|
<span className="m-sep">|</span>
|
||||||
|
<span>{ratio}</span>
|
||||||
|
<span className="m-sep">|</span>
|
||||||
|
<span>{product?.title || meta.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="gen-images" style={{ "--cols": candidateCount >= 4 ? 4 : 2, "--ratio": ratioVar } as React.CSSProperties}>
|
||||||
|
{Array.from({ length: candidateCount }).map((_, index) => (
|
||||||
|
<div className="gen-image" key={index}>
|
||||||
|
<div className="placeholder">
|
||||||
|
<span className="ph-frame">
|
||||||
|
{ratio} · #{index + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="gen-image-actions">
|
||||||
|
<button className="gen-img-btn" type="button" title="重跑">
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</button>
|
||||||
|
<button className="gen-img-btn" type="button" title="下载">
|
||||||
|
<Download size={14} />
|
||||||
|
</button>
|
||||||
|
<button className="gen-img-btn" type="button" title="更多">
|
||||||
|
<MoreHorizontal size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="gen-card-actions">
|
||||||
|
<button className="btn btn-sm" type="button">
|
||||||
|
<RefreshCw size={13} />
|
||||||
|
重跑
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-sm" type="button">
|
||||||
|
<Check size={13} />
|
||||||
|
采用并入资产库
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-sm btn-ghost" type="button" title="更多">
|
||||||
|
<MoreHorizontal size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{assets.length === 0 && (
|
||||||
|
<div className="iw-pv-empty">
|
||||||
|
<div className="mono">// NO RESULT YET</div>
|
||||||
|
<div className="title">还没有生成结果</div>
|
||||||
|
<div className="hint">
|
||||||
|
选择商品、填写提示词后点击 <b>立即生成</b>,结果会以候选卡片形式展示在这里。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,22 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useMemo, useState, type ReactNode } from "react";
|
||||||
import { Bell, Search } from "lucide-react";
|
import { Bell, Clapperboard, CreditCard, Info, Search, Users } from "lucide-react";
|
||||||
import type { Notification } from "../types";
|
import type { Notification } from "../types";
|
||||||
import type { Page } from "./route-config";
|
import type { Page } from "./route-config";
|
||||||
import { routeLabels } from "./route-config";
|
import { routeLabels } from "./route-config";
|
||||||
|
|
||||||
// 通知的 related_url(.html 风格)→ 应用内 Page
|
type TabKey = "all" | "unread" | "task" | "team" | "billing" | "system";
|
||||||
|
|
||||||
|
const PRI_LABEL: Record<string, string> = { ok: "已完成", warn: "需关注", err: "风险", info: "更新" };
|
||||||
|
const ZH_TYPE: Record<string, string> = { all: "全部", unread: "未读", task: "任务", team: "团队", billing: "计费", system: "系统" };
|
||||||
|
|
||||||
|
function typeIcon(type: string): ReactNode {
|
||||||
|
if (type === "task") return <Clapperboard size={14} />;
|
||||||
|
if (type === "team") return <Users size={14} />;
|
||||||
|
if (type === "billing") return <CreditCard size={14} />;
|
||||||
|
return <Info size={14} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知 related_url(.html 风格)→ 应用内 Page
|
||||||
function targetPage(n: Notification): Page {
|
function targetPage(n: Notification): Page {
|
||||||
const url = n.related_url || "";
|
const url = n.related_url || "";
|
||||||
if (url.includes("pipeline")) return "pipeline";
|
if (url.includes("pipeline")) return "pipeline";
|
||||||
@ -12,9 +24,23 @@ function targetPage(n: Notification): Page {
|
|||||||
if (url.includes("library")) return "library";
|
if (url.includes("library")) return "library";
|
||||||
if (url.includes("settings")) return "settingsNotify";
|
if (url.includes("settings")) return "settingsNotify";
|
||||||
if (url.includes("product")) return "products";
|
if (url.includes("product")) return "products";
|
||||||
|
if (url.includes("team")) return "team";
|
||||||
return "dashboard";
|
return "dashboard";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtTime(iso: string): string {
|
||||||
|
const diff = Math.round((Date.now() - new Date(iso).getTime()) / 60000);
|
||||||
|
if (diff < 1) return "刚刚";
|
||||||
|
if (diff < 60) return `${diff}m`;
|
||||||
|
if (diff < 1440) return `${Math.floor(diff / 60)}h`;
|
||||||
|
return `${Math.floor(diff / 1440)}d`;
|
||||||
|
}
|
||||||
|
function fmtFull(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const z = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAllRead, navigate }: {
|
export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAllRead, navigate }: {
|
||||||
notifications: Notification[];
|
notifications: Notification[];
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
@ -22,77 +48,148 @@ export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAll
|
|||||||
onMarkAllRead: () => void | Promise<unknown>;
|
onMarkAllRead: () => void | Promise<unknown>;
|
||||||
navigate: (page: Page) => void;
|
navigate: (page: Page) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [tab, setTab] = useState<TabKey>("all");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [selectedId, setSelectedId] = useState<string>("");
|
const [selectedId, setSelectedId] = useState("");
|
||||||
|
|
||||||
const visible = notifications.filter(
|
const counts = useMemo(
|
||||||
(item) => !query || `${item.title} ${item.brief}`.toLowerCase().includes(query.toLowerCase())
|
() => ({
|
||||||
|
all: notifications.length,
|
||||||
|
unread: notifications.filter((n) => !n.is_read).length,
|
||||||
|
task: notifications.filter((n) => n.notification_type === "task").length,
|
||||||
|
team: notifications.filter((n) => n.notification_type === "team").length,
|
||||||
|
billing: notifications.filter((n) => n.notification_type === "billing").length,
|
||||||
|
system: notifications.filter((n) => n.notification_type === "system").length
|
||||||
|
}),
|
||||||
|
[notifications]
|
||||||
);
|
);
|
||||||
const selected = notifications.find((item) => item.id === selectedId) || visible[0] || notifications[0] || null;
|
|
||||||
|
|
||||||
// 默认选中第一条(不自动标已读;仅显式点选才标)
|
const visible = useMemo(() => {
|
||||||
useEffect(() => {
|
const q = query.trim().toLowerCase();
|
||||||
if (!selectedId && notifications.length) setSelectedId(notifications[0].id);
|
return notifications.filter((n) => {
|
||||||
}, [selectedId, notifications]);
|
if (tab === "unread" && n.is_read) return false;
|
||||||
|
if (!["all", "unread"].includes(tab) && n.notification_type !== tab) return false;
|
||||||
|
if (q && ![n.title, n.brief, n.body, n.source, n.project_name, n.stage].join(" ").toLowerCase().includes(q)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [notifications, tab, query]);
|
||||||
|
|
||||||
function selectItem(item: Notification) {
|
const selected = notifications.find((n) => n.id === selectedId) || visible[0] || notifications[0] || null;
|
||||||
setSelectedId(item.id);
|
|
||||||
if (!item.is_read) void onMarkRead(item.id);
|
function selectItem(n: Notification) {
|
||||||
|
setSelectedId(n.id);
|
||||||
|
if (!n.is_read) void onMarkRead(n.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filters: Array<[TabKey, string, number]> = [
|
||||||
|
["all", "全部", counts.all],
|
||||||
|
["unread", "未读", counts.unread],
|
||||||
|
["task", "任务", counts.task],
|
||||||
|
["team", "团队", counts.team],
|
||||||
|
["billing", "计费", counts.billing],
|
||||||
|
["system", "系统", counts.system]
|
||||||
|
];
|
||||||
|
|
||||||
const target = selected ? targetPage(selected) : "dashboard";
|
const target = selected ? targetPage(selected) : "dashboard";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="msg-page">
|
||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h1>消息中心</h1>
|
<h1>消息中心</h1>
|
||||||
<div className="sub">
|
<div className="sub"><span className="mono">// {counts.unread} 条未读 · {notifications.length} 条总计</span> 任务提醒 · 团队协作 · 计费与系统公告</div>
|
||||||
<span className="mono">// {notifications.length} 条总计 · {unreadCount} 未读</span> 任务提醒 · 团队协作 · 计费与系统公告
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<div className="msg-head-actions">
|
||||||
<button className="btn" type="button" onClick={() => void onMarkAllRead()} disabled={unreadCount === 0}>全部已读</button>
|
<button className="btn" type="button" onClick={() => void onMarkAllRead()} disabled={unreadCount === 0}>全部标已读</button>
|
||||||
<button className="btn" type="button" onClick={() => navigate("settingsNotify")}>通知设置</button>
|
<button className="btn" type="button" onClick={() => navigate("settingsNotify")}>通知设置</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="msg-workbench">
|
<div className="msg-workbench">
|
||||||
<section className="msg-panel msg-inbox">
|
<section className="msg-panel msg-inbox">
|
||||||
<div className="msg-panel-h"><span className="ti">收件箱</span><span className="mono">// 显示 {visible.length} 条</span></div>
|
<div className="msg-panel-h"><span className="ti">收件箱</span><span className="mono">// 显示 {visible.length} 条</span></div>
|
||||||
<div className="msg-search"><Search size={14} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" /></div>
|
<div className="msg-filters">
|
||||||
<div className="msg-list">
|
{filters.map(([id, label, ct]) => (
|
||||||
{visible.length === 0 && (
|
<button key={id} className={`msg-filter ${tab === id ? "active" : ""}`} type="button" onClick={() => setTab(id)}>
|
||||||
<div className="msg-empty" style={{ padding: "24px 16px", color: "var(--black-alpha-48)", fontSize: "12px", fontFamily: "var(--font-mono)" }}>// 暂无消息</div>
|
{label}<span className="ct">{ct}</span>
|
||||||
)}
|
|
||||||
{visible.map((item) => (
|
|
||||||
<button className={`msg-item ${selected?.id === item.id ? "active" : ""} ${item.is_read ? "" : "unread"}`} type="button" key={item.id} onClick={() => selectItem(item)}>
|
|
||||||
<span className={`msg-type-ic ${item.notification_type}`}><Bell size={13} /></span>
|
|
||||||
<span className="msg-item-main"><span className="msg-item-title">{item.title}</span><span className="msg-brief">{item.brief}</span></span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="msg-search">
|
||||||
|
<Search />
|
||||||
|
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" />
|
||||||
|
</div>
|
||||||
|
<div className="msg-list">
|
||||||
|
{visible.length === 0 ? (
|
||||||
|
<div className="msg-empty"><Search /><span>没有符合条件的消息</span></div>
|
||||||
|
) : (
|
||||||
|
visible.map((n) => (
|
||||||
|
<button key={n.id} className={`msg-item ${selected?.id === n.id ? "active" : ""} ${n.is_read ? "read" : ""}`} type="button" onClick={() => selectItem(n)}>
|
||||||
|
<span className={`msg-type-ic ${n.notification_type}`}>{typeIcon(n.notification_type)}</span>
|
||||||
|
<span className="msg-item-main">
|
||||||
|
<span className="msg-item-row">
|
||||||
|
<span className="msg-dot"></span>
|
||||||
|
<span className="msg-item-title">{n.title}</span>
|
||||||
|
<span className="msg-time">{fmtTime(n.created_at)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="msg-brief">{n.brief}</span>
|
||||||
|
<span className="msg-item-foot">
|
||||||
|
<span className={`msg-priority ${n.priority}`}>{PRI_LABEL[n.priority] || "更新"}</span>
|
||||||
|
{n.project_name ? <span className="msg-priority">{n.project_name}</span> : null}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="msg-panel msg-detail">
|
<section className="msg-panel msg-detail">
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<>
|
<>
|
||||||
<div className="msg-detail-body">
|
<div className="msg-detail-body">
|
||||||
<div className="msg-detail-top">
|
<div className="msg-detail-top">
|
||||||
<span className={`msg-type-ic ${selected.notification_type}`}><Bell size={15} /></span>
|
<span className={`msg-type-ic ${selected.notification_type}`}>{typeIcon(selected.notification_type)}</span>
|
||||||
<div className="msg-detail-title">
|
<div className="msg-detail-title">
|
||||||
<h2>{selected.title}</h2>
|
<h2>{selected.title}</h2>
|
||||||
<div className="meta"><span>{selected.source || selected.notification_type}</span>{selected.stage ? <span> · {selected.stage}</span> : null}</div>
|
<div className="meta"><span>{selected.source || ZH_TYPE[selected.notification_type]}</span><span>// {ZH_TYPE[selected.notification_type]}</span><span>{fmtFull(selected.created_at)}</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
<span className={`msg-priority ${selected.priority}`}>{PRI_LABEL[selected.priority] || "更新"}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="msg-body-text">{selected.body || selected.brief}</p>
|
<p className="msg-body-text">{selected.body || selected.brief}</p>
|
||||||
<div className="msg-props"><span className="k">关联资源</span><span className="v">{routeLabels[target]}</span></div>
|
<div className="msg-props">
|
||||||
|
{([
|
||||||
|
["来源", selected.source || "-"],
|
||||||
|
["类别", ZH_TYPE[selected.notification_type] || selected.notification_type],
|
||||||
|
["项目", selected.project_name || "-"],
|
||||||
|
["阶段", selected.stage || "-"],
|
||||||
|
["负责人", selected.owner_label || "-"],
|
||||||
|
["费用", selected.cost_label || "-"],
|
||||||
|
["时间", fmtFull(selected.created_at)]
|
||||||
|
] as Array<[string, string]>).flatMap(([k, v]) => [
|
||||||
|
<span className="k" key={`${k}-k`}>{k}</span>,
|
||||||
|
<span className="v" key={`${k}-v`}>{v}</span>
|
||||||
|
])}
|
||||||
|
<span className="k">关联资源</span>
|
||||||
|
<span className="v"><a onClick={() => navigate(target)}>{routeLabels[target]} →</a></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="msg-detail-f">
|
||||||
|
{!selected.is_read && <button className="btn btn-ghost" type="button" onClick={() => void onMarkRead(selected.id)}>标为已读</button>}
|
||||||
|
<span className="spacer"></span>
|
||||||
|
<button className="btn btn-primary" type="button" onClick={() => navigate(target)}>进入{routeLabels[target]}</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="msg-detail-f"><span className="spacer" /><button className="btn btn-primary" type="button" onClick={() => navigate(target)}>进入{routeLabels[target]}</button></div>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="msg-detail-body"><p className="msg-body-text">// 选择左侧一条消息查看详情</p></div>
|
<div className="msg-detail-empty"><div className="ic"><Bell /></div><div>暂无消息</div></div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
|
<div className="msg-foot-note">
|
||||||
|
<span>// 消息保留 90 天 · 高风险任务会同时进入工作台队列</span>
|
||||||
|
<a onClick={() => navigate("settingsNotify")}>管理通知策略 →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { CSSProperties, FormEvent } from "react";
|
import type { CSSProperties, FormEvent, KeyboardEvent } from "react";
|
||||||
import { ArrowLeft, Upload } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import type { Product, Project } from "../types";
|
import type { Product, Project } from "../types";
|
||||||
import type { Page } from "./route-config";
|
import type { Page } from "./route-config";
|
||||||
import { Drawer } from "../components/overlays";
|
import { Drawer } from "../components/overlays";
|
||||||
|
import "../product-create-page.css";
|
||||||
|
|
||||||
type ProductPayload = {
|
type ProductPayload = {
|
||||||
title?: string;
|
title?: string;
|
||||||
@ -140,28 +141,164 @@ export function ProductCard({ product, onOpen }: { product: Product; onOpen: ()
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PC_PHOTO_SLOTS = ["主图", "细节 02", "细节 03", "细节 04", "细节 05"];
|
||||||
|
const PC_CAT_OPTIONS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
|
||||||
|
|
||||||
export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (payload: ProductPayload) => Promise<unknown> | void; onBack: () => void }) {
|
export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (payload: ProductPayload) => Promise<unknown> | void; onBack: () => void }) {
|
||||||
const [title, setTitle] = useState("补水保湿精华液");
|
const [title, setTitle] = useState("");
|
||||||
const [brand, setBrand] = useState("透真");
|
const [category, setCategory] = useState("");
|
||||||
const [category, setCategory] = useState("美妆个护");
|
const [price, setPrice] = useState("");
|
||||||
const [audience, setAudience] = useState("熬夜党 / 学生党 / 通勤白领");
|
const [audience, setAudience] = useState("");
|
||||||
const [description, setDescription] = useState("主打补水、舒缓、快速上脸,适合短视频痛点种草。");
|
const [points, setPoints] = useState<string[]>([]);
|
||||||
const [points, setPoints] = useState("透明质酸 + B5\n30g 大容量精华\n0 香精 0 酒精");
|
const [pointDraft, setPointDraft] = useState("");
|
||||||
|
|
||||||
|
const ready = title.trim().length > 0 && category.length > 0;
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (title.trim().length === 0) missing.push("商品名");
|
||||||
|
if (category.length === 0) missing.push("品类");
|
||||||
|
missing.push("≥1 张图");
|
||||||
|
|
||||||
|
function addPoint(event: KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (event.key !== "Enter") return;
|
||||||
|
event.preventDefault();
|
||||||
|
const value = pointDraft.trim();
|
||||||
|
if (!value) return;
|
||||||
|
setPoints((list) => [...list, value]);
|
||||||
|
setPointDraft("");
|
||||||
|
}
|
||||||
|
function removePoint(index: number) {
|
||||||
|
setPoints((list) => list.filter((_, position) => position !== index));
|
||||||
|
}
|
||||||
|
|
||||||
function submit(event: FormEvent) {
|
function submit(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onCreate({ title, brand, category, target_audience: audience, description, specs: { source: "product-create-upload" }, selling_points: points.split("\n").filter(Boolean).map((item, index) => ({ title: item, detail: item, sort_order: index })) });
|
if (!ready) return;
|
||||||
|
onCreate({
|
||||||
|
title,
|
||||||
|
category,
|
||||||
|
target_audience: audience,
|
||||||
|
specs: { source: "product-create-upload", ...(price ? { price } : {}) },
|
||||||
|
selling_points: points.map((item, index) => ({ title: item, detail: item, sort_order: index }))
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<section className="product-create-page">
|
||||||
<div className="page-head"><div><h1>上传创建商品</h1><div className="sub"><span className="mono">// product-create-upload</span> · 图片 / 文案 / 卖点一次补齐</div></div><div className="actions"><button className="btn btn-ghost" type="button" onClick={onBack}><ArrowLeft size={13} />返回商品库</button></div></div>
|
<div className="page-head">
|
||||||
<form className="create-product-layout" onSubmit={submit}>
|
<div>
|
||||||
<section className="card-hard create-upload-zone with-corners"><span className="corner-tr">+</span><span className="corner-bl">+</span><div className="mono muted-2">[ PRODUCT IMAGES ]</div><div className="upload-stage"><Upload size={28} /><strong>拖入商品图片或点击上传</strong><span>// 文件后续走资产库 TOS 上传接口</span></div></section>
|
<h1>新建商品</h1>
|
||||||
<section className="pane create-form-pane"><div className="pane-h"><strong>商品信息</strong><span className="spacer" /><span className="mono muted-2">AUTO SAVE READY</span></div><div className="field"><label className="field-label">商品名称<span className="req">*</span></label><input className="input" value={title} onChange={(event) => setTitle(event.target.value)} required /></div><div className="two-col"><div className="field"><label className="field-label">品牌</label><input className="input" value={brand} onChange={(event) => setBrand(event.target.value)} /></div><div className="field"><label className="field-label">类目</label><input className="input" value={category} onChange={(event) => setCategory(event.target.value)} /></div></div><div className="field"><label className="field-label">目标人群</label><input className="input" value={audience} onChange={(event) => setAudience(event.target.value)} /></div><div className="field"><label className="field-label">商品描述</label><textarea className="textarea" value={description} onChange={(event) => setDescription(event.target.value)} /></div><div className="field"><label className="field-label">核心卖点<span className="req">*</span></label><textarea className="textarea" value={points} onChange={(event) => setPoints(event.target.value)} /></div><div className="drawer-actions"><button className="btn" type="button" onClick={onBack}>取消</button><button className="btn btn-primary" type="submit">创建商品并查看详情</button></div></section>
|
<div className="sub"><span className="mono">// 上传原图 + 填写基本信息</span> · 保存后可在工作台逐步丰富素材</div>
|
||||||
<aside className="wiz-preview"><div className="pv-h"><span>资料完整度</span><span className="live">LIVE</span></div><div className="pv-title">{title}</div><div className="pv-section"><div className="lbl">下一步</div><ul className="pv-list"><li>进入商品详情页</li><li>生成模特图 / 平台套图</li><li>创建视频项目</li></ul></div></aside>
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
<button className="btn btn-ghost" type="button" onClick={onBack}><ArrowLeft size={14} />返回商品库</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<div className="form-grid">
|
||||||
|
|
||||||
|
{/* 左:商品原图 */}
|
||||||
|
<div className="form-card">
|
||||||
|
<div className="card-h">
|
||||||
|
<h3>商品原图</h3>
|
||||||
|
<span className="req-tag">必填</span>
|
||||||
|
</div>
|
||||||
|
<div className="card-sub">// 1-5 张 · 这是后续所有 AI 生成的源材料</div>
|
||||||
|
<div className="photo-grid">
|
||||||
|
<div className="photo-slot photo-slot-add" role="button" tabIndex={0} title="上传图片">
|
||||||
|
<span className="plus">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
|
||||||
|
</span>
|
||||||
|
<span>添加</span>
|
||||||
|
</div>
|
||||||
|
{PC_PHOTO_SLOTS.slice(1).map((label) => (
|
||||||
|
<div className="photo-slot" key={label}><span className="slot-label">{label}</span></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="upload-tip">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v4M12 16h.01" /></svg>
|
||||||
|
<span>建议上传 <strong>正面 / 侧面 / 细节 / 包装</strong> 4 张,后续在工作台生成的<strong>白底三视图</strong>更准确。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右:基本信息 */}
|
||||||
|
<div className="form-card">
|
||||||
|
<div className="card-h">
|
||||||
|
<h3>基本信息</h3>
|
||||||
|
<span className="req-tag">必填</span>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">商品名称<span className="req">*</span></label>
|
||||||
|
<input className="input" value={title} onChange={(event) => setTitle(event.target.value)} placeholder="例: 透真玻尿酸补水面膜" required />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">品类<span className="req">*</span></label>
|
||||||
|
<select className="select" value={category} onChange={(event) => setCategory(event.target.value)} required>
|
||||||
|
<option value="">— 选择品类 —</option>
|
||||||
|
{PC_CAT_OPTIONS.map((option) => <option key={option}>{option}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field field-last">
|
||||||
|
<label className="field-label">价格(元)</label>
|
||||||
|
<input className="input" type="number" value={price} onChange={(event) => setPrice(event.target.value)} placeholder="选填 · 仅用于素材生成参考" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 卖点 & 人群 */}
|
||||||
|
<div className="form-card form-card-wide">
|
||||||
|
<div className="card-h">
|
||||||
|
<h3>卖点 & 人群</h3>
|
||||||
|
<span className="opt-tag">选填 · 推荐</span>
|
||||||
|
</div>
|
||||||
|
<div className="ai-tip">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8L12 3z" /></svg>
|
||||||
|
<span>填上这两项,后续 AI 生脚本(<strong>痛点种草 / 剧情带货</strong> 等模板)质量明显更高 —— 系统会用卖点构造钩子,用人群定语气。现在不填也可以,做视频项目时仍可补。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">核心卖点</label>
|
||||||
|
<div className="field-hint">3-5 条要点,回车添加</div>
|
||||||
|
<ul className="bullet-list">
|
||||||
|
{points.map((point, index) => (
|
||||||
|
<li className="bl-item" key={index}>
|
||||||
|
<span className="num">{index + 1}</span>
|
||||||
|
<span className="bl-text">{point}</span>
|
||||||
|
<button className="bl-x" type="button" aria-label="删除" onClick={() => removePoint(index)}>
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li className="bl-add">
|
||||||
|
<span className="num">+</span>
|
||||||
|
<input className="bl-input" value={pointDraft} onChange={(event) => setPointDraft(event.target.value)} onKeyDown={addPoint} placeholder="例: 玻尿酸双效保湿,4 小时持久水润" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="field field-last">
|
||||||
|
<label className="field-label">目标人群</label>
|
||||||
|
<input className="input" value={audience} onChange={(event) => setAudience(event.target.value)} placeholder="例: 22-32 岁女性、敏感肌、办公室通勤" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部操作 */}
|
||||||
|
<div className="form-foot">
|
||||||
|
<span className="req-info">
|
||||||
|
{ready
|
||||||
|
? <>// 必填检查:<span className="ok">已全部完成 ✓</span> · 可进入工作台</>
|
||||||
|
: <>// 必填检查:<span className="miss">{missing.join(" / ")}</span> 未完成</>}
|
||||||
|
</span>
|
||||||
|
<div className="foot-actions">
|
||||||
|
<button className="btn" type="button" onClick={onBack}>取消</button>
|
||||||
|
<button className="btn btn-primary" type="submit" disabled={!ready}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
|
||||||
|
保存并进入工作台
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,33 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { CSSProperties, FormEvent } from "react";
|
import type { CSSProperties, FormEvent } from "react";
|
||||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
|
||||||
import type { Product, Project } from "../types";
|
import type { Product, Project } from "../types";
|
||||||
import type { Page } from "./route-config";
|
import type { Page } from "./route-config";
|
||||||
import { ConfirmModal, EmptyPanel } from "../components/overlays";
|
import { ConfirmModal, EmptyPanel } from "../components/overlays";
|
||||||
|
import "../project-wizard-page.css";
|
||||||
|
|
||||||
|
// 时长 / 脚本风格 / 人设 — 与 projects-new.html 基线对齐(创建页仅作视觉选择,
|
||||||
|
// 脚本走向细节进入 Stage 1;onCreate 契约只携带 name + product)。
|
||||||
|
const WIZ_DURATIONS = [
|
||||||
|
{ id: "0-10", label: "0-10 秒", shots: [3, 4], tag: "黄金完播" },
|
||||||
|
{ id: "0-15", label: "0-15 秒", shots: [4, 5], tag: "完播率最佳" },
|
||||||
|
{ id: "0-30", label: "0-30 秒", shots: [6, 8], tag: "卖点详解" },
|
||||||
|
{ id: "0-60", label: "0-60 秒", shots: [10, 12], tag: "故事化" }
|
||||||
|
];
|
||||||
|
const WIZ_STYLES = [
|
||||||
|
{ id: "pain", name: "痛点种草", note: "用户痛点切入,以「我懂你」的口吻引出产品。", tag: "最常用" },
|
||||||
|
{ id: "review", name: "开箱测评", note: "朋友式分享,从开箱到使用感受娓娓道来。", tag: "" },
|
||||||
|
{ id: "compare", name: "对比展示", note: "「用前 vs 用后 / 同类 vs 本品」直观呈现。", tag: "" }
|
||||||
|
];
|
||||||
|
const WIZ_PERSONAS = [
|
||||||
|
{ id: "urban", name: "都市白领女性", sub: "25-30 岁", metric: "大盘消费力", dur: "0-15", style: "pain" },
|
||||||
|
{ id: "bestie", name: "闺蜜种草", sub: "邻家女孩", metric: "复购最高", dur: "0-15", style: "pain" },
|
||||||
|
{ id: "ceo", name: "总裁亲选", sub: "创始人 IP", metric: "30 万销额案例", dur: "0-30", style: "pain" },
|
||||||
|
{ id: "reviewer", name: "专业测评师", sub: "垂类达人", metric: "互动 +30%", dur: "0-30", style: "review" },
|
||||||
|
{ id: "mom", name: "实用宝妈", sub: "家庭决策者", metric: "母婴/家清稳", dur: "0-30", style: "pain" },
|
||||||
|
{ id: "genz", name: "学生党", sub: "Z 世代 18-24", metric: "平价快消", dur: "0-10", style: "compare" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品」→ 每页 7 商品
|
||||||
|
|
||||||
export function ProjectWizardPage({ products, onBack, onCreate }: {
|
export function ProjectWizardPage({ products, onBack, onCreate }: {
|
||||||
products: Product[];
|
products: Product[];
|
||||||
@ -12,26 +36,311 @@ export function ProjectWizardPage({ products, onBack, onCreate }: {
|
|||||||
}) {
|
}) {
|
||||||
const [productId, setProductId] = useState(products[0]?.id || "");
|
const [productId, setProductId] = useState(products[0]?.id || "");
|
||||||
const product = products.find((item) => item.id === productId) || products[0];
|
const product = products.find((item) => item.id === productId) || products[0];
|
||||||
const [name, setName] = useState(() => `${products[0]?.title || "商品"} · 短视频 · ${new Date().toLocaleDateString("zh-CN")}`);
|
const [name, setName] = useState(() => `${(products[0]?.title || "商品").split(" ")[0]} · 痛点种草 · v1`);
|
||||||
|
|
||||||
|
// Step 1 · 商品选择器本地交互态
|
||||||
|
const [pickSearch, setPickSearch] = useState("");
|
||||||
|
const [pickCat, setPickCat] = useState("全部");
|
||||||
|
const [catOpen, setCatOpen] = useState(false);
|
||||||
|
const [pickView, setPickView] = useState<"grid" | "list">("grid");
|
||||||
|
const [pickPage, setPickPage] = useState(1);
|
||||||
|
|
||||||
|
// Step 2 · 配置(视觉选择,详见 Stage 1)
|
||||||
|
const [duration, setDuration] = useState<string | null>("0-15");
|
||||||
|
const [scriptStyle, setScriptStyle] = useState<string | null>("pain");
|
||||||
|
const [persona, setPersona] = useState<string | null>(null);
|
||||||
|
const [recoDismissed, setRecoDismissed] = useState(false);
|
||||||
|
const [points, setPoints] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!productId && products[0]) setProductId(products[0].id);
|
if (!productId && products[0]) setProductId(products[0].id);
|
||||||
}, [productId, products]);
|
}, [productId, products]);
|
||||||
|
|
||||||
function submit(event: FormEvent) {
|
// 分类清单
|
||||||
event.preventDefault();
|
const cats = useMemo(
|
||||||
if (product) void onCreate({ name: name || `${product.title} · 短视频`, product: product.id });
|
() => ["全部", ...Array.from(new Set(products.map((p) => p.category || "未分类")))],
|
||||||
|
[products]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 筛选 + 排序后的商品
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = pickSearch.trim().toLowerCase();
|
||||||
|
return products.filter((p) => {
|
||||||
|
const cat = p.category || "未分类";
|
||||||
|
if (pickCat !== "全部" && cat !== pickCat) return false;
|
||||||
|
if (q) {
|
||||||
|
const blob = `${p.title} ${cat} ${(p.selling_points || []).map((s) => s.title).join(" ")}`.toLowerCase();
|
||||||
|
if (!blob.includes(q)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [products, pickSearch, pickCat]);
|
||||||
|
|
||||||
|
const hasFilter = !!pickSearch || pickCat !== "全部";
|
||||||
|
const total = filtered.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / WIZ_PAGE_SIZE));
|
||||||
|
const cur = Math.min(pickPage, totalPages);
|
||||||
|
const pageList = filtered.slice((cur - 1) * WIZ_PAGE_SIZE, cur * WIZ_PAGE_SIZE);
|
||||||
|
|
||||||
|
function selectProduct(id: string) {
|
||||||
|
setProductId(id);
|
||||||
|
const p = products.find((item) => item.id === id);
|
||||||
|
if (p) {
|
||||||
|
const seed: Record<string, boolean> = {};
|
||||||
|
(p.selling_points || []).forEach((sp) => { seed[sp.title] = false; });
|
||||||
|
setPoints(seed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearPickFilters() {
|
||||||
|
setPickSearch("");
|
||||||
|
setPickCat("全部");
|
||||||
|
setPickPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPreset() {
|
||||||
|
const p = WIZ_PERSONAS.find((item) => item.id === persona);
|
||||||
|
if (!p) return;
|
||||||
|
setDuration(p.dur);
|
||||||
|
setScriptStyle(p.style);
|
||||||
|
setRecoDismissed(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const personaObj = WIZ_PERSONAS.find((p) => p.id === persona);
|
||||||
|
const durObj = WIZ_DURATIONS.find((d) => d.id === duration);
|
||||||
|
const styleObj = WIZ_STYLES.find((s) => s.id === scriptStyle);
|
||||||
|
const showReco =
|
||||||
|
!!personaObj && !!duration && !!scriptStyle && !recoDismissed &&
|
||||||
|
(personaObj.dur !== duration || personaObj.style !== scriptStyle);
|
||||||
|
const recoDur = personaObj && WIZ_DURATIONS.find((d) => d.id === personaObj.dur);
|
||||||
|
const recoStyle = personaObj && WIZ_STYLES.find((s) => s.id === personaObj.style);
|
||||||
|
|
||||||
|
const product1Done = !!productId;
|
||||||
|
const config2Done = !!duration && !!styleObj && name.trim().length >= 2;
|
||||||
|
const canStart = product1Done && config2Done;
|
||||||
|
|
||||||
|
function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canStart || !product) return;
|
||||||
|
void onCreate({ name: name.trim() || `${product.title} · 短视频`, product: product.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
const productCover = (p: Product): CSSProperties | undefined => {
|
||||||
|
const file = p.cover_asset || p.images?.find((img) => img.is_primary)?.asset || p.images?.[0]?.asset;
|
||||||
|
return file ? ({ ["--mock-media-url"]: `url(${file})` } as CSSProperties) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<section className="project-wizard-page">
|
||||||
<div className="page-head"><div><h1>新建视频项目</h1><div className="sub"><span className="mono">// 选择商品 → 创建项目 → 进入 Stage 1 脚本</span></div></div><div className="actions"><button className="btn btn-ghost" type="button" onClick={onBack}>退出</button></div></div>
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>新建项目</h1>
|
||||||
|
<div className="sub"><span className="mono">// 商品 → 配置 · 2 步开始生成</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
<button className="btn btn-ghost" type="button" onClick={onBack}>退出</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form className="wizard" onSubmit={submit}>
|
<form className="wizard" onSubmit={submit}>
|
||||||
<nav className="steps" aria-label="新建项目步骤"><div className="step active"><div className="num">1</div><div><div className="label">选择商品</div><div className="desc">{product?.title || "未选择"}</div></div></div><div className="step"><div className="num">2</div><div><div className="label">项目名称</div><div className="desc">自动生成可修改</div></div></div><div className="step"><div className="num">3</div><div><div className="label">进入脚本</div><div className="desc">Stage 1 配置脚本</div></div></div></nav>
|
{/* ── 左侧步骤轨 ── */}
|
||||||
<div><section className="wiz-pane active"><div className="wiz-step-h"><h2>为哪个商品创建视频?</h2><p>按定稿,创建页只绑定商品和项目名称,脚本风格、卖点重点放到 Stage 1。</p></div><div className="product-select-grid">{products.map((item) => <button className={`product-pick ${productId === item.id ? "selected" : ""}`} type="button" key={item.id} onClick={() => setProductId(item.id)}><div className="placeholder"><span className="ph-frame">{item.title}</span></div><strong>{item.title}</strong><span>{item.category || "未分类"} · {item.selling_points.length} 个卖点</span></button>)}</div>{products.length === 0 && <EmptyPanel title="还没有商品" action="去创建商品" onAction={onBack} />}<div className="field" style={{ marginTop: 18 }}><label className="field-label">项目名称</label><input className="input" value={name} onChange={(event) => setName(event.target.value)} /></div><div className="wiz-foot"><button className="btn" type="button" onClick={onBack}><ArrowLeft size={13} />返回</button><div className="hstack"><span className="muted-2 mono">// 下一步:流水线 Stage 1</span><button className="btn btn-primary btn-lg" type="submit" disabled={!product}><ArrowRight size={13} />创建并进入脚本</button></div></div></section></div>
|
<nav className="steps" aria-label="新建项目步骤">
|
||||||
<aside className="wiz-preview"><div className="pv-h"><span>实时预估</span><span className="live">LIVE</span></div><div className="pv-title">{name || product?.title || "未命名项目"}</div><div className="pv-metrics"><div className="pv-metric"><div className="l">片段</div><div className="v">4<small>段</small></div></div><div className="pv-metric accent"><div className="l">总时长</div><div className="v">60<small>s</small></div></div><div className="pv-metric"><div className="l">阶段</div><div className="v">5</div></div></div></aside>
|
<div className={`step ${product1Done ? "done" : "active"}`}>
|
||||||
|
<div className="num">{product1Done ? (
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg>
|
||||||
|
) : "1"}</div>
|
||||||
|
<div>
|
||||||
|
<div className="label">选择商品</div>
|
||||||
|
<div className="desc">{product?.title || "未选择"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`step ${config2Done ? "done" : product1Done ? "active" : ""}`}>
|
||||||
|
<div className="num">{config2Done ? (
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg>
|
||||||
|
) : "2"}</div>
|
||||||
|
<div>
|
||||||
|
<div className="label">项目配置</div>
|
||||||
|
<div className="desc">{durObj && styleObj ? `${durObj.label} · ${styleObj.name}` : "时长 · 风格 · 人物"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* ── 主体 ── */}
|
||||||
|
<div className="wiz-body">
|
||||||
|
{/* Step 1 · 商品选择 */}
|
||||||
|
<section className="step-pane-wrap">
|
||||||
|
<div className="wiz-pane">
|
||||||
|
<div className="wiz-step-h">
|
||||||
|
<h2>第 1 步 · 选择商品</h2>
|
||||||
|
<p>从商品库选一个 SKU。它的主图与卖点会被 LLM 作为脚本/资产生成的素材。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pp-toolbar">
|
||||||
|
<div className="search-inline">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
|
||||||
|
<input type="text" placeholder="搜索商品名称、标签" value={pickSearch} onChange={(event) => { setPickSearch(event.target.value); setPickPage(1); }} />
|
||||||
|
</div>
|
||||||
|
<div className={`pp-chip-wrap${catOpen ? " open" : ""}`}>
|
||||||
|
<button className={`pp-chip${pickCat !== "全部" ? " active" : ""}`} type="button" onClick={() => setCatOpen((open) => !open)}>
|
||||||
|
<span>{pickCat === "全部" ? "全部分类" : pickCat}</span>
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
|
||||||
|
</button>
|
||||||
|
<div className="pp-menu">
|
||||||
|
{cats.map((c) => (
|
||||||
|
<div className={`mi${pickCat === c ? " selected" : ""}`} key={c} onClick={() => { setPickCat(c); setPickPage(1); setCatOpen(false); }}>
|
||||||
|
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6"><polyline points="3 8 7 12 13 4" /></svg>
|
||||||
|
<span>{c}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasFilter && (
|
||||||
|
<button className="pp-clear" type="button" onClick={clearPickFilters}>
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 4l8 8M12 4l-8 8" /></svg>
|
||||||
|
清空筛选
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pp-result-meta">// 显示 {pageList.length} / {total} 个商品{hasFilter ? " (已筛选)" : ""}</div>
|
||||||
|
|
||||||
|
<div className={`pp-grid${pickView === "list" ? " list-view" : ""}`}>
|
||||||
|
<div className="pp-create-card" onClick={onBack}>
|
||||||
|
<div className="pc-plus"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg></div>
|
||||||
|
<div className="pc-t">创建新商品</div>
|
||||||
|
<div className="pc-d">// 在此添加一个新商品</div>
|
||||||
|
</div>
|
||||||
|
{total === 0 ? (
|
||||||
|
<div className="pp-empty">// NO MATCH<br />没有符合筛选条件的商品 <span className="reset" onClick={clearPickFilters}>[ 清空筛选 ]</span></div>
|
||||||
|
) : (
|
||||||
|
pageList.map((p) => (
|
||||||
|
<div className={`product-card${productId === p.id ? " selected" : ""}`} key={p.id} onClick={() => selectProduct(p.id)}>
|
||||||
|
<div className={`placeholder product-thumb${productCover(p) ? " has-mock-media" : ""}`} style={productCover(p)}><span className="ph-frame">{p.title} · 1200×800</span></div>
|
||||||
|
<div className="product-body">
|
||||||
|
<div className="product-name">{p.title}</div>
|
||||||
|
<div className="product-cat">{p.category || "未分类"}</div>
|
||||||
|
<div className="product-date">{(p.created_at || "").slice(0, 10)} 创建</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{total > WIZ_PAGE_SIZE && (
|
||||||
|
<div className="pp-pager">
|
||||||
|
<span className="total">共 {total} 条</span>
|
||||||
|
<div className="pages">
|
||||||
|
<button type="button" disabled={cur === 1} onClick={() => setPickPage(cur - 1)}>‹</button>
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
|
||||||
|
<button type="button" key={n} className={n === cur ? "active" : ""} onClick={() => setPickPage(n)}>{n}</button>
|
||||||
|
))}
|
||||||
|
<button type="button" disabled={cur === totalPages} onClick={() => setPickPage(cur + 1)}>›</button>
|
||||||
|
</div>
|
||||||
|
<span className="page-size">每页 {WIZ_PAGE_SIZE} 条</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pp-bottom-tip">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v5M12 16h.01" /></svg>
|
||||||
|
<span>找不到想要的商品?可<a onClick={onBack}>创建新商品</a>,或前往 <a onClick={onBack}>商品库 · 管理商品</a></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{products.length === 0 && <EmptyPanel title="还没有商品" action="去创建商品" onAction={onBack} />}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Step 2 · 项目配置 */}
|
||||||
|
<section className="step-pane-wrap">
|
||||||
|
<div className="wiz-pane">
|
||||||
|
<div className="wiz-step-h">
|
||||||
|
<h2>第 2 步 · 项目配置</h2>
|
||||||
|
<p>这些设置会影响 LLM 生成脚本的方向,确认后会进入流水线第 1 步(脚本生成)。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="config-row">
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">项目名称<span className="req">*</span></label>
|
||||||
|
<input className="input" value={name} onChange={(event) => setName(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">视频时长<span className="req">*</span></label>
|
||||||
|
<select className="select duration-select" value={duration || ""} onChange={(event) => setDuration(event.target.value || null)}>
|
||||||
|
<option value="" disabled>选择时长</option>
|
||||||
|
{WIZ_DURATIONS.map((d) => (
|
||||||
|
<option value={d.id} key={d.id}>{d.label} · {d.shots[0]}-{d.shots[1]} 镜</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">脚本风格</label>
|
||||||
|
<div className="opt-row cols-4">
|
||||||
|
{WIZ_STYLES.map((s) => (
|
||||||
|
<div className={`opt-card${scriptStyle === s.id ? " selected" : ""}`} key={s.id} onClick={() => setScriptStyle(s.id)}>
|
||||||
|
<h4>{s.name}</h4>
|
||||||
|
<div className="note">{s.note}</div>
|
||||||
|
{s.tag && <span className="badge">[ {s.tag} ]</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">人物设定</label>
|
||||||
|
<div className="opt-row cols-6">
|
||||||
|
{WIZ_PERSONAS.map((p) => (
|
||||||
|
<div className={`opt-card${persona === p.id ? " selected" : ""}`} key={p.id} onClick={() => { setPersona(p.id); setRecoDismissed(false); }}>
|
||||||
|
<h4>{p.name}</h4>
|
||||||
|
<div className="sub">{p.sub}</div>
|
||||||
|
<div className="metric"><span className="val">{p.metric}</span></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showReco && recoDur && recoStyle && durObj && styleObj && (
|
||||||
|
<div className="reco-bubble">
|
||||||
|
<span className="ic">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 16v-4M12 8h.01" /></svg>
|
||||||
|
</span>
|
||||||
|
<div className="txt">
|
||||||
|
<span>抖音同人设 TOP 视频更常用 <strong>{recoDur.label}</strong> + <strong>{recoStyle.name}</strong></span>
|
||||||
|
<span className="meta">当前 {durObj.label} · {styleObj.name} → 推荐换为同人设最优组合</span>
|
||||||
|
</div>
|
||||||
|
<button className="btn-apply" type="button" onClick={applyPreset}>一键套用</button>
|
||||||
|
<button className="dismiss" type="button" onClick={() => setRecoDismissed(true)} aria-label="忽略">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.keys(points).length > 0 && (
|
||||||
|
<div className="field" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="field-label">关键卖点(可勾选要重点突出的)</label>
|
||||||
|
<div className="theme-pill-row">
|
||||||
|
{Object.entries(points).map(([k, v]) => (
|
||||||
|
<button className={`theme-pill${v ? " active" : ""}`} type="button" key={k} aria-pressed={v} onClick={() => setPoints((prev) => ({ ...prev, [k]: !prev[k] }))}>
|
||||||
|
{v && <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg>}
|
||||||
|
<span>{k}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── 底部「开始」CTA ── */}
|
||||||
|
<div className="wiz-start-bar">
|
||||||
|
<button className={`btn-start${canStart ? "" : " disabled"}`} type="submit" disabled={!canStart}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M5 3l14 9-14 9V3z" /></svg>
|
||||||
|
<span>开始</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,18 +1,452 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { LogOut, Upload } from "lucide-react";
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
LogOut,
|
||||||
|
Monitor,
|
||||||
|
ShieldCheck,
|
||||||
|
Sliders,
|
||||||
|
Smartphone,
|
||||||
|
Upload,
|
||||||
|
User as UserIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import type { Team, User } from "../types";
|
import type { Team, User } from "../types";
|
||||||
import { ConfirmModal, SettingRow, TeamModal } from "../components/overlays";
|
import { TeamModal } from "../components/overlays";
|
||||||
|
|
||||||
export function SettingsPage({ user, team, initialSection = "profile" }: { user: User; team: Team; initialSection?: string }) {
|
type SectionKey = "profile" | "security" | "notify" | "pref" | "display";
|
||||||
const [section, setSection] = useState(initialSection);
|
|
||||||
const [modal, setModal] = useState<"" | "avatar" | "logout">("");
|
const NAV: Array<{ group: string; items: Array<{ key: SectionKey; label: string; icon: ReactNode; badge?: string }> }> = [
|
||||||
const sections = [["profile", "个人资料"], ["security", "安全"], ["notify", "通知"], ["pref", "创作偏好"], ["display", "显示"]];
|
{
|
||||||
|
group: "个人",
|
||||||
|
items: [
|
||||||
|
{ key: "profile", label: "个人信息", icon: <UserIcon /> },
|
||||||
|
{ key: "security", label: "安全", icon: <ShieldCheck />, badge: "3 设备" },
|
||||||
|
{ key: "notify", label: "通知", icon: <Bell />, badge: "4/4" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "偏好",
|
||||||
|
items: [
|
||||||
|
{ key: "pref", label: "创作默认", icon: <Sliders /> },
|
||||||
|
{ key: "display", label: "显示", icon: <Monitor /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const TEMPLATE_CHOICES = [
|
||||||
|
{ v: "pain", t: "痛点种草", d: "// 30s 默认档" },
|
||||||
|
{ v: "unbox", t: "开箱测评", d: "// 45s 默认档" },
|
||||||
|
{ v: "compare", t: "对比展示", d: "// 45s 默认档" },
|
||||||
|
{ v: "howto", t: "教程演示", d: "// 60s 默认档" },
|
||||||
|
{ v: "drama", t: "剧情带货", d: "// 60s 默认档" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SUBTITLE_CHOICES = [
|
||||||
|
{ v: "big-variety", t: "大字综艺", d: "// 抖音热门" },
|
||||||
|
{ v: "clean-ec", t: "简洁电商", d: "// 信息清晰" },
|
||||||
|
{ v: "premium", t: "高级排版", d: "// 居中衬线" },
|
||||||
|
{ v: "bullet", t: "弹幕轻量", d: "// 滚动出现" },
|
||||||
|
{ v: "emphasis", t: "强调爆款", d: "// 高对比" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DURATIONS = ["30", "45", "60"];
|
||||||
|
|
||||||
|
const DEVICES: Array<{ name: string; meta: string; current?: boolean; phone?: boolean }> = [
|
||||||
|
{ name: "MacBook Pro · Chrome", meta: "// 上海 · 2026-05-21 14:08 · IP 116.xxx.xxx.42", current: true },
|
||||||
|
{ name: "iPhone 15 · Safari", meta: "// 上海 · 2026-05-20 21:43", phone: true },
|
||||||
|
{ name: "Windows · Edge", meta: "// 杭州 · 2026-05-18 09:12" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const NOTIFY_ROWS: Array<{ key: string; title: string; sub?: string; channels: string }> = [
|
||||||
|
{ key: "n-export", title: "项目完成通知", sub: "// 视频导出后", channels: "站内 · 邮件 · 短信" },
|
||||||
|
{ key: "n-fail", title: "任务失败告警", channels: "站内 · 邮件" },
|
||||||
|
{ key: "n-quota", title: "额度不足提醒", sub: "// 团队或个人剩余 < 20%", channels: "站内 · 短信" },
|
||||||
|
{ key: "n-login", title: "异地登录告警", channels: "短信" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Switch({ checked, disabled, onChange }: { checked: boolean; disabled?: boolean; onChange?: (next: boolean) => void }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<label className="switch">
|
||||||
<div className="page-head"><div><h1>设置</h1><div className="sub"><span className="mono">// profile · security · notify · preference · display</span></div></div><div className="actions"><button className="btn" type="button">取消</button><button className="btn btn-primary" type="button">保存设置</button></div></div>
|
<input type="checkbox" checked={checked} disabled={disabled} onChange={(event) => onChange?.(event.target.checked)} />
|
||||||
<div className="settings-layout"><aside className="settings-side">{sections.map(([key, label]) => <button className={section === key ? "active" : ""} type="button" key={key} onClick={() => setSection(key)}>{label}</button>)}<button className="logout-pill" type="button" onClick={() => setModal("logout")}><LogOut size={13} />退出当前设备</button></aside><section className="settings-main">{section === "profile" && <div className="pane"><h3>个人资料</h3><div className="profile-row"><div className="av-big">{user.username.slice(0, 1).toUpperCase()}</div><button className="btn btn-sm" type="button" onClick={() => setModal("avatar")}>上传新头像</button></div><div className="field"><label className="field-label">用户名</label><input className="input" value={user.username} readOnly /></div><div className="field"><label className="field-label">邮箱</label><input className="input" value={user.email || ""} readOnly /></div><div className="field"><label className="field-label">团队</label><input className="input" value={team.name} readOnly /></div></div>}{section === "security" && <div className="pane"><h3>安全</h3><SettingRow title="登录密码" desc="上次更新: 2026-05-28" action="修改" /><SettingRow title="双因素认证" desc="建议超管开启" toggle /></div>}{section === "notify" && <div className="pane"><h3>通知策略</h3><SettingRow title="导出完成" desc="成片 / 套图完成后提醒" toggle checked /><SettingRow title="任务失败" desc="失败和扣费异常必须提醒" toggle checked /><SettingRow title="额度预警" desc="余额低于阈值时提醒" toggle checked /></div>}{section === "pref" && <div className="pane"><h3>创作偏好</h3><SettingRow title="自动水印" desc="VIP 可关闭" toggle checked /></div>}{section === "display" && <div className="pane"><h3>显示偏好</h3><div className="field"><label className="field-label">主题</label><select className="select"><option>跟随系统</option><option>浅色</option></select></div></div>}</section></div>
|
<span className="slider" />
|
||||||
<TeamModal open={modal === "avatar"} title="上传头像" subtitle="// JPG / PNG / WebP" icon={<Upload size={16} />} close={() => setModal("")}><div className="upload-zone">点击或拖入图片上传<br /><span className="mono">// 头像上传接口待后端补充</span></div></TeamModal>
|
</label>
|
||||||
<ConfirmModal open={modal === "logout"} title="退出当前账号" detail="当前有未保存的设置变更时,退出后这些变更不会保存。" confirmText="退出" onCancel={() => setModal("")} onConfirm={() => setModal("")} />
|
);
|
||||||
</>
|
}
|
||||||
|
|
||||||
|
export function SettingsPage({ user, team, initialSection = "profile" }: { user: User; team: Team; initialSection?: string }) {
|
||||||
|
const normalizedInitial = (["profile", "security", "notify", "pref", "display"] as const).includes(initialSection as SectionKey)
|
||||||
|
? (initialSection as SectionKey)
|
||||||
|
: "profile";
|
||||||
|
const [section, setSection] = useState<SectionKey>(normalizedInitial);
|
||||||
|
const [modal, setModal] = useState<"" | "avatar" | "logout">("");
|
||||||
|
|
||||||
|
const [template, setTemplate] = useState("pain");
|
||||||
|
const [duration, setDuration] = useState("60");
|
||||||
|
const [subtitle, setSubtitle] = useState("big-variety");
|
||||||
|
const [twoFactor, setTwoFactor] = useState(false);
|
||||||
|
const [notify, setNotify] = useState<Record<string, boolean>>({ "n-export": true, "n-fail": true, "n-quota": true, "n-login": true });
|
||||||
|
|
||||||
|
const avatarChar = useMemo(() => (user.username || "李").slice(0, 1).toUpperCase(), [user.username]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="settings-page">
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>设置</h1>
|
||||||
|
<div className="sub"><span className="mono">// 个人信息 · 偏好 · 通知 · 安全</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
<button className="btn" type="button" disabled>取消</button>
|
||||||
|
<button className="btn btn-primary" type="button" disabled>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
|
||||||
|
保存所有变更
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-grid">
|
||||||
|
{/* 左侧 nav */}
|
||||||
|
<aside className="settings-nav" role="tablist" aria-label="设置分区">
|
||||||
|
{NAV.map((group, gi) => (
|
||||||
|
<div key={group.group}>
|
||||||
|
<div className="nav-h" style={gi > 0 ? { marginTop: 16 } : undefined}>{group.group}</div>
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<a
|
||||||
|
key={item.key}
|
||||||
|
href={`#sec-${item.key}`}
|
||||||
|
className={section === item.key ? "active" : ""}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={section === item.key}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setSection(item.key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
{item.badge ? <span className="nav-badge">{item.badge}</span> : null}
|
||||||
|
<span className="nav-dot" aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="nav-h" style={{ marginTop: 16 }}>账号</div>
|
||||||
|
<button className="logout-pill" type="button" onClick={() => setModal("logout")}>
|
||||||
|
<LogOut />
|
||||||
|
<span>退出登录</span>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 右侧内容 */}
|
||||||
|
<main>
|
||||||
|
{section === "profile" && (
|
||||||
|
<section className="pane" aria-label="个人信息">
|
||||||
|
<h3>个人信息</h3>
|
||||||
|
<div className="pane-desc">// 头像、姓名、联系方式 · 邮箱用于接收通知</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="lbl">头像</div>
|
||||||
|
<div className="val">
|
||||||
|
<div className="avatar-edit">
|
||||||
|
<div className="av-big">{avatarChar}</div>
|
||||||
|
<div className="av-actions">
|
||||||
|
<button className="btn btn-sm" type="button" onClick={() => setModal("avatar")}>上传新头像</button>
|
||||||
|
<button className="btn btn-ghost btn-sm" type="button">恢复默认</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="lbl">显示名称<span className="req">*</span></div>
|
||||||
|
<div className="val"><input className="input" defaultValue={user.username} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="lbl">登录邮箱</div>
|
||||||
|
<div className="val">
|
||||||
|
<input className="input" defaultValue={user.email || ""} />
|
||||||
|
<button className="btn btn-ghost btn-sm" type="button">验证</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="lbl">手机号</div>
|
||||||
|
<div className="val">
|
||||||
|
<input className="input" defaultValue="138****8000" />
|
||||||
|
<button className="btn btn-ghost btn-sm" type="button">更换</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="lbl">所属团队<div className="lbl-sub">// 一人一团队</div></div>
|
||||||
|
<div className="val">
|
||||||
|
<span className="static">{team.name}</span>
|
||||||
|
<span className="role-tag"><span className="dot" />超管 · 创建者</span>
|
||||||
|
<a href="#team" className="row-link">管理团队 →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="lbl">用户 ID<div className="lbl-sub">// 不可改</div></div>
|
||||||
|
<div className="val"><span className="static mono">{user.id}</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === "security" && (
|
||||||
|
<section className="pane" aria-label="安全">
|
||||||
|
<h3>安全</h3>
|
||||||
|
<div className="pane-desc">// 登录密码、双因素、在用设备</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="lbl">登录密码</div>
|
||||||
|
<div className="val">
|
||||||
|
<span className="static mono">●●●●●●●●●●</span>
|
||||||
|
<span className="row-note" style={{ marginLeft: "auto" }}>上次修改 2026-04-12</span>
|
||||||
|
<button className="btn btn-sm" type="button" style={{ marginLeft: 10 }}>修改</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="lbl">两步验证<div className="lbl-sub">// 推荐开启</div></div>
|
||||||
|
<div className="val">
|
||||||
|
<Switch checked={twoFactor} onChange={setTwoFactor} />
|
||||||
|
<span className="switch-note">短信 + Authenticator</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="sub-head">在用设备</h3>
|
||||||
|
<div className="pane-desc">// 不在此列表上的设备登录会触发短信告警</div>
|
||||||
|
<div className="device-list">
|
||||||
|
{DEVICES.map((device) => (
|
||||||
|
<div className="device-row" key={device.name}>
|
||||||
|
<div className="ic">
|
||||||
|
{device.phone ? (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="2" width="12" height="20" rx="2" /><path d="M11 18h2" /></svg>
|
||||||
|
) : (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="14" rx="2" /><path d="M2 20h20" /></svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="nm">{device.name}{device.current ? <span className="tag-cur">CURRENT</span> : null}</div>
|
||||||
|
<div className="meta">{device.meta}</div>
|
||||||
|
</div>
|
||||||
|
<div className="spacer" />
|
||||||
|
{device.current
|
||||||
|
? <span className="row-note">当前会话</span>
|
||||||
|
: <button className="btn btn-ghost btn-sm" type="button">下线</button>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 14 }}>
|
||||||
|
<button className="btn" type="button">下线所有其他设备</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === "notify" && (
|
||||||
|
<section className="pane" aria-label="通知">
|
||||||
|
<h3>通知</h3>
|
||||||
|
<div className="pane-desc">// 邮件、短信、站内提示开关</div>
|
||||||
|
{NOTIFY_ROWS.map((row) => (
|
||||||
|
<div className="form-row" key={row.key}>
|
||||||
|
<div className="lbl">{row.title}{row.sub ? <div className="lbl-sub">{row.sub}</div> : null}</div>
|
||||||
|
<div className="val">
|
||||||
|
<Switch checked={!!notify[row.key]} onChange={(next) => setNotify((prev) => ({ ...prev, [row.key]: next }))} />
|
||||||
|
<span className="switch-note">{row.channels}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === "pref" && (
|
||||||
|
<section className="pane" aria-label="创作默认">
|
||||||
|
<h3>创作默认</h3>
|
||||||
|
<div className="pane-desc">// 新建项目时的预填值,可在向导中改</div>
|
||||||
|
|
||||||
|
<div className="form-row row-top">
|
||||||
|
<div className="lbl">默认模板</div>
|
||||||
|
<div className="val">
|
||||||
|
<div className="pref-choices">
|
||||||
|
{TEMPLATE_CHOICES.map((choice) => (
|
||||||
|
<div
|
||||||
|
key={choice.v}
|
||||||
|
className={`pref-choice ${template === choice.v ? "selected" : ""}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setTemplate(choice.v)}
|
||||||
|
>
|
||||||
|
<div className="t">{choice.t}</div>
|
||||||
|
<div className="d">{choice.d}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="lbl">默认时长档</div>
|
||||||
|
<div className="val">
|
||||||
|
<div className="duration-row">
|
||||||
|
{DURATIONS.map((d) => (
|
||||||
|
<span
|
||||||
|
key={d}
|
||||||
|
className={`dur-chip ${duration === d ? "selected" : ""}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setDuration(d)}
|
||||||
|
>
|
||||||
|
{d}s
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="switch-note" style={{ marginLeft: 10 }}>// 60s = 4 段 × 15s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row row-top">
|
||||||
|
<div className="lbl">默认字幕样式</div>
|
||||||
|
<div className="val">
|
||||||
|
<div className="pref-choices">
|
||||||
|
{SUBTITLE_CHOICES.map((choice) => (
|
||||||
|
<div
|
||||||
|
key={choice.v}
|
||||||
|
className={`pref-choice ${subtitle === choice.v ? "selected" : ""}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setSubtitle(choice.v)}
|
||||||
|
>
|
||||||
|
<div className="t">{choice.t}</div>
|
||||||
|
<div className="d">{choice.d}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="lbl">默认 BGM 库</div>
|
||||||
|
<div className="val">
|
||||||
|
<select className="select" defaultValue="kapian">
|
||||||
|
<option value="kapian">抖音 Top10 卡点曲库</option>
|
||||||
|
<option value="emotion">情绪向 · 治愈/悬念</option>
|
||||||
|
<option value="urban">都市电子 · 通勤场景</option>
|
||||||
|
<option value="none">无 BGM</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="lbl">默认转场</div>
|
||||||
|
<div className="val">
|
||||||
|
<select className="select" defaultValue="fade">
|
||||||
|
<option value="none">无转场</option>
|
||||||
|
<option value="fade">淡入淡出 · 0.3s</option>
|
||||||
|
<option value="slide">滑动 · 0.3s</option>
|
||||||
|
<option value="zoom">缩放 · 0.3s</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="lbl">导出水印<div className="lbl-sub">// VIP 可关闭</div></div>
|
||||||
|
<div className="val">
|
||||||
|
<Switch checked disabled />
|
||||||
|
<span className="switch-note">右下角 · Airshelf</span>
|
||||||
|
<a href="#account" className="row-link">升级 VIP →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === "display" && (
|
||||||
|
<section className="pane" aria-label="显示">
|
||||||
|
<h3>显示</h3>
|
||||||
|
<div className="pane-desc">// 界面外观与语言</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="lbl">外观</div>
|
||||||
|
<div className="val">
|
||||||
|
<select className="select" defaultValue="system">
|
||||||
|
<option value="system">跟随系统</option>
|
||||||
|
<option value="light">浅色</option>
|
||||||
|
<option value="dark" disabled>深色(V2)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="lbl">语言</div>
|
||||||
|
<div className="val">
|
||||||
|
<select className="select" defaultValue="zh">
|
||||||
|
<option value="zh">简体中文</option>
|
||||||
|
<option value="en" disabled>English(V2)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="lbl">表格密度</div>
|
||||||
|
<div className="val">
|
||||||
|
<select className="select" defaultValue="standard">
|
||||||
|
<option value="compact">紧凑</option>
|
||||||
|
<option value="standard">标准</option>
|
||||||
|
<option value="loose">宽松</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="settings-foot">// Airshelf · v2.1 · build 20260521</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 上传头像 modal · 仅视觉还原,无后端接入 */}
|
||||||
|
<TeamModal
|
||||||
|
open={modal === "avatar"}
|
||||||
|
title="上传头像"
|
||||||
|
subtitle="// 用于个人主页、评论与团队展示"
|
||||||
|
icon={<Upload size={16} />}
|
||||||
|
close={() => setModal("")}
|
||||||
|
footer={
|
||||||
|
<button className="btn btn-primary" type="button" onClick={() => setModal("")}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
|
||||||
|
确认使用
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="av-up-preview-row">
|
||||||
|
<div className="av-up-preview">{avatarChar}</div>
|
||||||
|
<div className="av-up-preview-meta">
|
||||||
|
<div className="t">当前头像 · 默认</div>
|
||||||
|
<div className="d">// 系统生成 · 取姓氏首字</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="upload-zone" role="button" tabIndex={0} aria-label="点击或拖入图片上传">
|
||||||
|
<span className="uz-ic">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" /></svg>
|
||||||
|
</span>
|
||||||
|
<div><strong>点击选择</strong> · 或拖入图片</div>
|
||||||
|
<span className="uz-hint">JPG / PNG / WebP · ≤ 2 MB · 推荐 256 × 256</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="av-up-rules">
|
||||||
|
<div className="li">最大 2 MB · 长宽比建议 1:1 · 系统会自动裁切为圆形</div>
|
||||||
|
<div className="li">不要上传含他人肖像的图片,违规可能导致账号封停</div>
|
||||||
|
</div>
|
||||||
|
</TeamModal>
|
||||||
|
|
||||||
|
{/* 退出登录确认 modal · 仅视觉还原,无后端接入 */}
|
||||||
|
<TeamModal
|
||||||
|
open={modal === "logout"}
|
||||||
|
title="退出当前账号"
|
||||||
|
subtitle="// LOG OUT CURRENT SESSION"
|
||||||
|
icon={<LogOut size={16} />}
|
||||||
|
close={() => setModal("")}
|
||||||
|
footer={
|
||||||
|
<button className="btn btn-primary" type="button" onClick={() => setModal("")}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><path d="m16 17 5-5-5-5" /><path d="M21 12H9" /></svg>
|
||||||
|
确认退出
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="logout-confirm-copy">确认后将退出当前设备上的 Airshelf,再次使用需要重新登录。</p>
|
||||||
|
<div className="logout-confirm-points">
|
||||||
|
<div className="li">项目、资产、团队成员与余额数据都会保留</div>
|
||||||
|
<div className="li">仅影响当前浏览器会话,不会下线其他设备</div>
|
||||||
|
</div>
|
||||||
|
</TeamModal>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
188
core/frontend/src/settings-page.css
Normal file
188
core/frontend/src/settings-page.css
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
/* 设置页 · 从 public/exact/settings.html 内联 <style> 忠实移植,整段 scope 进 .settings-page 防外泄 · 只用 token */
|
||||||
|
.settings-page {
|
||||||
|
/* ─── 设置布局:左 nav + 右 panel ─── */
|
||||||
|
.settings-grid { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 24px; align-items: start; }
|
||||||
|
|
||||||
|
.settings-nav { position: sticky; top: 16px; }
|
||||||
|
.settings-nav .nav-h { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; padding: 0 12px 8px; }
|
||||||
|
.settings-nav :where(a, button) { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px 12px; font: inherit; font-size: 13px; color: var(--accent-black); border-radius: var(--r-md); border: 1px solid transparent; background: transparent; cursor: pointer; text-decoration: none; text-align: left; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); position: relative; }
|
||||||
|
.settings-nav :where(a, button):hover { background: var(--background-lighter); }
|
||||||
|
.settings-nav :where(a, button):focus-visible { outline: 2px solid var(--heat); outline-offset: 2px; }
|
||||||
|
.settings-nav a.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); font-weight: 600; }
|
||||||
|
.settings-nav :where(a, button) svg { width: 16px; height: 16px; stroke-width: 1.5; flex: 0 0 auto; }
|
||||||
|
.settings-nav a .nav-badge { margin-left: auto; font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-48); padding: 1px 6px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-pill); letter-spacing: .02em; line-height: 14px; }
|
||||||
|
.settings-nav a.active .nav-badge { color: var(--heat); background: var(--accent-white); border-color: var(--heat-20); }
|
||||||
|
.settings-nav a .nav-dot { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); width: 6px; height: 6px; border-radius: 50%; background: var(--heat); display: none; }
|
||||||
|
.settings-nav a.has-changes .nav-dot { display: block; }
|
||||||
|
.settings-nav a.active .nav-dot { right: -4px; }
|
||||||
|
.settings-nav .logout-pill {
|
||||||
|
width: calc(100% - 24px);
|
||||||
|
height: 38px;
|
||||||
|
margin: 4px 12px 0;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--accent-black);
|
||||||
|
border-color: var(--accent-black);
|
||||||
|
color: var(--accent-white);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.settings-nav .logout-pill:hover,
|
||||||
|
.settings-nav .logout-pill:focus-visible {
|
||||||
|
background: var(--black-alpha-88);
|
||||||
|
border-color: var(--black-alpha-88);
|
||||||
|
color: var(--accent-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── pane ─── */
|
||||||
|
.pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 24px; margin-bottom: 16px; }
|
||||||
|
.pane h3 { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
|
||||||
|
.pane .pane-desc { font-size: 12px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; margin-bottom: 18px; }
|
||||||
|
|
||||||
|
/* ─── form row ─── */
|
||||||
|
.form-row { display: grid; grid-template-columns: 160px minmax(0, 1fr); gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--border-faint); align-items: center; }
|
||||||
|
.form-row:last-child { border-bottom: 0; }
|
||||||
|
.form-row .lbl { font-size: 12.5px; color: var(--black-alpha-56); }
|
||||||
|
.form-row .lbl .req { color: var(--accent-crimson); margin-left: 2px; }
|
||||||
|
.form-row .lbl-sub { font-size: 11px; color: var(--black-alpha-48); font-family: var(--font-mono); margin-top: 2px; letter-spacing: .02em; }
|
||||||
|
.form-row .val { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||||
|
.form-row .val .input, .form-row .val .select { width: 100%; max-width: 380px; }
|
||||||
|
.form-row .val .static { font-size: 13px; color: var(--accent-black); font-variant-numeric: tabular-nums; }
|
||||||
|
.form-row .val .static.mono { font-family: var(--font-mono); font-size: 12.5px; color: var(--black-alpha-56); }
|
||||||
|
.form-row .val .role-tag { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: var(--r-pill); font-size: 11px; font-weight: 500; background: var(--heat-12); color: var(--heat); }
|
||||||
|
.form-row .val .role-tag .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--heat); }
|
||||||
|
.form-row .val .row-link { font-size: 12px; color: var(--heat); text-decoration: none; margin-left: auto; }
|
||||||
|
.form-row .val .row-note { font-size: 11px; color: var(--black-alpha-48); font-family: var(--font-mono); }
|
||||||
|
.form-row .val .switch-note { font-size: 11.5px; color: var(--black-alpha-48); font-family: var(--font-mono); }
|
||||||
|
.form-row.row-top { align-items: flex-start; }
|
||||||
|
.form-row.row-top .lbl { padding-top: 4px; }
|
||||||
|
.form-row.row-top .val { display: block; }
|
||||||
|
|
||||||
|
/* ─── 头像上传 ─── */
|
||||||
|
.avatar-edit { display: flex; align-items: center; gap: 16px; }
|
||||||
|
.avatar-edit .av-big { width: 64px; height: 64px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: grid; place-items: center; font-size: 24px; font-weight: 600; color: var(--accent-black); overflow: hidden; }
|
||||||
|
.avatar-edit .av-big img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.avatar-edit .av-actions { display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
/* ─── toggle switch ─── */
|
||||||
|
.switch { position: relative; width: 36px; height: 20px; flex: 0 0 36px; display: inline-block; }
|
||||||
|
.switch input { opacity: 0; width: 0; height: 0; }
|
||||||
|
.switch .slider { position: absolute; inset: 0; background: var(--black-alpha-24); border-radius: 20px; cursor: pointer; transition: background var(--t-base); }
|
||||||
|
.switch .slider::before { content: ''; position: absolute; left: 2px; top: 2px; width: 16px; height: 16px; background: var(--accent-white); border-radius: 50%; transition: transform var(--t-base); }
|
||||||
|
.switch input:checked + .slider { background: var(--heat); }
|
||||||
|
.switch input:checked + .slider::before { transform: translateX(16px); }
|
||||||
|
.switch input:disabled + .slider { cursor: not-allowed; opacity: .55; }
|
||||||
|
|
||||||
|
/* ─── 偏好选项卡 ─── */
|
||||||
|
.pref-choices { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 8px; max-width: 540px; }
|
||||||
|
.pref-choice { padding: 10px 12px; border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background var(--t-base), border-color var(--t-base); }
|
||||||
|
.pref-choice:hover { background: var(--background-lighter); }
|
||||||
|
.pref-choice.selected { border-color: var(--heat); background: var(--heat-12); }
|
||||||
|
.pref-choice .t { font-size: 12.5px; font-weight: 600; color: var(--accent-black); }
|
||||||
|
.pref-choice .d { font-size: 11px; color: var(--black-alpha-48); margin-top: 2px; font-family: var(--font-mono); letter-spacing: .02em; }
|
||||||
|
.pref-choice.selected .t { color: var(--heat); }
|
||||||
|
|
||||||
|
/* ─── 时长档 ─── */
|
||||||
|
.duration-row { display: flex; gap: 8px; }
|
||||||
|
.dur-chip { padding: 6px 14px; border: 1px solid var(--border-faint); border-radius: var(--r-md); font-size: 13px; cursor: pointer; font-family: var(--font-mono); font-variant-numeric: tabular-nums; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); background: var(--surface); }
|
||||||
|
.dur-chip:hover { background: var(--background-lighter); }
|
||||||
|
.dur-chip.selected { border-color: var(--heat); background: var(--heat-12); color: var(--heat); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ─── 设备列表 ─── */
|
||||||
|
.device-list { /* 容器 · 仅承载行 */ }
|
||||||
|
.device-row { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border-faint); }
|
||||||
|
.device-row:last-child { border-bottom: 0; }
|
||||||
|
.device-row .ic { width: 36px; height: 36px; border-radius: var(--r-md); background: var(--background-lighter); display: grid; place-items: center; color: var(--black-alpha-56); flex: 0 0 36px; }
|
||||||
|
.device-row .ic svg { width: 18px; height: 18px; }
|
||||||
|
.device-row .nm { font-size: 13px; font-weight: 500; display: flex; align-items: center; }
|
||||||
|
.device-row .meta { font-size: 11.5px; color: var(--black-alpha-48); font-family: var(--font-mono); margin-top: 2px; letter-spacing: .02em; }
|
||||||
|
.device-row .tag-cur { font-family: var(--font-mono); font-size: 10.5px; padding: 1px 6px; background: var(--accent-forest); color: var(--accent-white); border-radius: var(--r-sm); margin-left: 8px; letter-spacing: .04em; font-weight: 600; }
|
||||||
|
.device-row .spacer { margin-left: auto; }
|
||||||
|
.device-row .row-note { font-size: 11px; color: var(--black-alpha-48); font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
.sub-head { margin-top: 24px; }
|
||||||
|
|
||||||
|
/* ─── 头像上传 modal 正文 · 装订线分隔 ─── */
|
||||||
|
.av-up-preview-row { display: flex; align-items: center; gap: 14px; padding-bottom: 14px; margin-bottom: 14px; position: relative; }
|
||||||
|
.av-up-preview-row::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: repeating-linear-gradient(to right, var(--border-faint) 0, var(--border-faint) 4px, transparent 4px, transparent 8px); }
|
||||||
|
.av-up-preview { width: 64px; height: 64px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: grid; place-items: center; font-size: 22px; font-weight: 600; color: var(--accent-black); overflow: hidden; flex: 0 0 64px; }
|
||||||
|
.av-up-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.av-up-preview-meta { min-width: 0; }
|
||||||
|
.av-up-preview-meta .t { font-size: 12.5px; font-weight: 600; color: var(--accent-black); margin-bottom: 3px; letter-spacing: .01em; }
|
||||||
|
.av-up-preview-meta .d { font-size: 11px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; line-height: 1.55; }
|
||||||
|
|
||||||
|
.av-up-rules { margin-top: 12px; padding-top: 10px; border-top: 1px dashed var(--border-faint); font-size: 11px; color: var(--black-alpha-56); font-family: var(--font-mono); letter-spacing: .02em; line-height: 1.7; }
|
||||||
|
.av-up-rules .li { display: flex; gap: 8px; }
|
||||||
|
.av-up-rules .li::before { content: '//'; color: var(--black-alpha-32); flex: 0 0 auto; }
|
||||||
|
|
||||||
|
/* 头像上传 modal 的 upload-zone · 共享样式仅 scope 在 .np-body,故在此页内补齐 uz-ic / uz-hint */
|
||||||
|
.upload-zone {
|
||||||
|
border: 1.5px dashed var(--black-alpha-24);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 22px 20px;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--background-lighter);
|
||||||
|
color: var(--black-alpha-56);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||||
|
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
|
||||||
|
}
|
||||||
|
.upload-zone:hover, .upload-zone.dragover { border-color: var(--heat); background: var(--heat-8); color: var(--heat); }
|
||||||
|
.upload-zone:hover .uz-ic, .upload-zone.dragover .uz-ic { background: var(--heat); color: var(--accent-white); border-color: var(--heat); }
|
||||||
|
.upload-zone strong { color: var(--heat); font-weight: 600; }
|
||||||
|
.upload-zone .uz-ic {
|
||||||
|
width: 40px; height: 40px;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--heat);
|
||||||
|
border: 1px solid var(--heat-20);
|
||||||
|
display: grid; place-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.upload-zone .uz-ic svg { width: 18px; height: 18px; }
|
||||||
|
.upload-zone .uz-hint { display: block; margin-top: 2px; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }
|
||||||
|
|
||||||
|
/* ─── 退出登录确认 modal 正文 ─── */
|
||||||
|
.logout-confirm-copy { margin: 0 0 12px; color: var(--black-alpha-72); }
|
||||||
|
.logout-confirm-points {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--background-lighter);
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
}
|
||||||
|
.logout-confirm-points .li {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--black-alpha-64);
|
||||||
|
}
|
||||||
|
.logout-confirm-points .li::before {
|
||||||
|
content: '//';
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--black-alpha-32);
|
||||||
|
}
|
||||||
|
.logout-unsaved-note {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
border: 1px solid var(--heat-20);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--heat-12);
|
||||||
|
color: var(--heat);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 页脚 build 标记 ─── */
|
||||||
|
.settings-foot { text-align: center; padding: 24px 0 8px; color: var(--black-alpha-32); font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em; }
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.settings-grid { grid-template-columns: 1fr; }
|
||||||
|
.settings-nav { position: static; }
|
||||||
|
.form-row { grid-template-columns: 1fr; gap: 6px; }
|
||||||
|
}
|
||||||
|
}
|
||||||
62
core/qa/visual-parity/shot-p1.mjs
Normal file
62
core/qa/visual-parity/shot-p1.mjs
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
// shot-p1.mjs · P1 像素还原验收截图(React 实页 vs public/exact 基线)
|
||||||
|
// 用法: node shot-p1.mjs [outDir]
|
||||||
|
// 前置: 后端 8010 + 前端 5173 在跑;playwright 已装;chromium 已缓存。
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
|
||||||
|
const BASE = "http://127.0.0.1:5180";
|
||||||
|
const API = "http://127.0.0.1:8010";
|
||||||
|
const OUT = process.argv[2] || "shots-p1";
|
||||||
|
mkdirSync(OUT, { recursive: true });
|
||||||
|
|
||||||
|
// [name, React 路由, 基线 html]
|
||||||
|
const PAGES = [
|
||||||
|
["settings", "/settings", "/exact/settings.html"],
|
||||||
|
["messages", "/messages", "/exact/messages.html"],
|
||||||
|
["asset-factory", "/asset-factory", "/exact/asset-factory.html"],
|
||||||
|
["image-optimize", "/image-optimize", "/exact/image-optimize.html"],
|
||||||
|
["model-photo", "/model-photo", "/exact/model-photo.html"],
|
||||||
|
["platform-cover", "/platform-cover", "/exact/platform-cover.html"],
|
||||||
|
["product-create", "/products/new", "/exact/product-create-upload.html"],
|
||||||
|
["project-wizard", "/projects/new", "/exact/projects-new.html"]
|
||||||
|
];
|
||||||
|
|
||||||
|
const res = await fetch(`${API}/api/auth/login/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: "airshelf", password: "Restraint2026" })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
const token = data.token;
|
||||||
|
if (!token) {
|
||||||
|
console.error("login failed:", JSON.stringify(data));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log("token ok");
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
|
||||||
|
await ctx.addInitScript((t) => localStorage.setItem("airshelf_token", t), token);
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
page.on("pageerror", (e) => console.error(" pageerror:", e.message));
|
||||||
|
|
||||||
|
for (const [name, route, baseline] of PAGES) {
|
||||||
|
try {
|
||||||
|
await page.goto(BASE + route, { waitUntil: "networkidle", timeout: 30000 });
|
||||||
|
await page.waitForTimeout(1400);
|
||||||
|
await page.screenshot({ path: `${OUT}/${name}.react.png`, fullPage: true });
|
||||||
|
console.log("react ", name);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("FAIL react", name, e.message);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await page.goto(BASE + baseline, { waitUntil: "networkidle", timeout: 30000 });
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
await page.screenshot({ path: `${OUT}/${name}.baseline.png`, fullPage: true });
|
||||||
|
console.log("baseline", name);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("FAIL baseline", name, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await browser.close();
|
||||||
|
console.log("DONE ->", OUT);
|
||||||
Loading…
x
Reference in New Issue
Block a user